From c0c02bf6bb2b5800bf0a545dddf948ce56db1103 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 27 Sep 2023 18:28:27 +0200 Subject: [PATCH 001/968] Restore state of trend sensor (#100332) * Restoring state of trend sensor * Handle unknown state & parametrize tests --- .../components/trend/binary_sensor.py | 10 ++++++- tests/components/trend/test_binary_sensor.py | 30 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 089e82b0f07..2d00f35202c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_FRIENDLY_NAME, CONF_SENSORS, + STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -37,6 +38,7 @@ from homeassistant.helpers.event import ( async_track_state_change_event, ) from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, EventType from homeassistant.util.dt import utcnow @@ -116,7 +118,7 @@ async def async_setup_platform( async_add_entities(sensors) -class SensorTrend(BinarySensorEntity): +class SensorTrend(BinarySensorEntity, RestoreEntity): """Representation of a trend Sensor.""" _attr_should_poll = False @@ -194,6 +196,12 @@ class SensorTrend(BinarySensorEntity): ) ) + if not (state := await self.async_get_last_state()): + return + if state.state == STATE_UNKNOWN: + return + self._state = state.state == STATE_ON + async def async_update(self) -> None: """Get the latest data and update the states.""" # Remove outdated samples diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py index c477b9a11fe..cccf1add61b 100644 --- a/tests/components/trend/test_binary_sensor.py +++ b/tests/components/trend/test_binary_sensor.py @@ -2,16 +2,19 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant import config as hass_config, setup from homeassistant.components.trend.const import DOMAIN from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State import homeassistant.util.dt as dt_util from tests.common import ( assert_setup_component, get_fixture_path, get_test_home_assistant, + mock_restore_cache, ) @@ -413,3 +416,28 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("binary_sensor.test_trend_sensor") is None assert hass.states.get("binary_sensor.second_test_trend_sensor") + + +@pytest.mark.parametrize( + ("saved_state", "restored_state"), + [("on", "on"), ("off", "off"), ("unknown", "unknown")], +) +async def test_restore_state( + hass: HomeAssistant, saved_state: str, restored_state: str +) -> None: + """Test we restore the trend state.""" + mock_restore_cache(hass, (State("binary_sensor.test_trend_sensor", saved_state),)) + + assert await setup.async_setup_component( + hass, + "binary_sensor", + { + "binary_sensor": { + "platform": "trend", + "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}}, + } + }, + ) + await hass.async_block_till_done() + + assert hass.states.get("binary_sensor.test_trend_sensor").state == restored_state From 97f24b855f12815ad5768d52841189bb753e9f96 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 27 Sep 2023 19:03:06 +0200 Subject: [PATCH 002/968] Bump version to 2023.11.0dev0 (#101013) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 053877b608e..1e81bed2965 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,7 +37,7 @@ env: PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 5 BLACK_CACHE_VERSION: 1 - HA_SHORT_VERSION: "2023.10" + HA_SHORT_VERSION: "2023.11" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" # 10.3 is the oldest supported version diff --git a/homeassistant/const.py b/homeassistant/const.py index 5585413e97b..31256e502a4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 10 +MINOR_VERSION: Final = 11 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/pyproject.toml b/pyproject.toml index e4d3876d9f7..6d6d3d126c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.10.0.dev0" +version = "2023.11.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 9fe2c08913602742d0fb7d30ac0ad23c33681f7f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 27 Sep 2023 20:14:56 +0200 Subject: [PATCH 003/968] Update astroid to 2.15.8 (#101007) --- homeassistant/components/airvisual/__init__.py | 1 - homeassistant/components/smart_meter_texas/sensor.py | 1 - requirements_test.txt | 2 +- 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 1403cc94346..e07400f2764 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,7 +421,6 @@ class AirVisualEntity(CoordinatorEntity): self._entry = entry self.entity_description = description - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index f54da815b26..a35a92bf257 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,7 +73,6 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): self._attr_native_value = self.meter.reading self.async_write_ha_state() - # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self): """Subscribe to updates.""" await super().async_added_to_hass() diff --git a/requirements_test.txt b/requirements_test.txt index 15404c159b9..d12ee6de114 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.7 +astroid==2.15.8 coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 From b3b235cbb742b6dfc5d31f0e319a9786850403c3 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Wed, 27 Sep 2023 20:16:00 +0200 Subject: [PATCH 004/968] Add homeassistant reload_all translatable service name and description (#100437) * Update services.yaml * Update strings.json * Update strings.json --- homeassistant/components/homeassistant/services.yaml | 2 ++ homeassistant/components/homeassistant/strings.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 2b5fd3fc686..892e577490d 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -60,3 +60,5 @@ reload_config_entry: text: save_persistent_states: + +reload_all: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 53510a94f01..a3435a8d1f5 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -125,6 +125,10 @@ "save_persistent_states": { "name": "Save persistent states", "description": "Saves the persistent states immediately. Maintains the normal periodic saving interval." + }, + "reload_all": { + "name": "Reload all", + "description": "Reload all YAML configuration that can be reloaded without restarting Home Assistant." } } } From 473d20712c58946627cb9eeb339c409615a72cbd Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 27 Sep 2023 21:17:39 +0300 Subject: [PATCH 005/968] Migrate islamic prayer times sensor unique_id to include entry_id (#100814) * Migrate sensor unique_id to include entry_id * Apply suggestion --- .../islamic_prayer_times/__init__.py | 17 ++++++- .../islamic_prayer_times/test_init.py | 45 +++++++++++++++++++ .../islamic_prayer_times/test_sensor.py | 1 - 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index d8810b0ad45..86ee94f7269 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -3,8 +3,8 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, entity_registry as er from .const import DOMAIN from .coordinator import IslamicPrayerDataUpdateCoordinator @@ -16,6 +16,19 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" + + @callback + def update_unique_id( + entity_entry: er.RegistryEntry, + ) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if not entity_entry.unique_id.startswith(f"{config_entry.entry_id}-"): + new_unique_id = f"{config_entry.entry_id}-{entity_entry.unique_id}" + return {"new_unique_id": new_unique_id} + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + coordinator = IslamicPrayerDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index b1cf8f2c9a5..6b3b112e042 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -9,7 +9,9 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import ( NEW_PRAYER_TIMES, @@ -145,3 +147,46 @@ async def test_update(hass: HomeAssistant) -> None: async_fire_time_changed(hass, future) await hass.async_block_till_done() assert pt_data.data == NEW_PRAYER_TIMES_TIMESTAMPS + + +@pytest.mark.parametrize( + ("object_id", "old_unique_id"), + [ + ( + "fajer_prayer", + "Fajr", + ), + ( + "dhuhr_prayer", + "Dhuhr", + ), + ], +) +async def test_migrate_unique_id( + hass: HomeAssistant, object_id: str, old_unique_id: str +) -> None: + """Test unique id migration.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + ent_reg = er.async_get(hass) + + entity: er.RegistryEntry = ent_reg.async_get_or_create( + suggested_object_id=object_id, + domain=SENSOR_DOMAIN, + platform=islamic_prayer_times.DOMAIN, + unique_id=old_unique_id, + config_entry=entry, + ) + assert entity.unique_id == old_unique_id + + with patch( + "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), freeze_time(NOW): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = ent_reg.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == f"{entry.entry_id}-{old_unique_id}" diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index a5b9b9c8a8d..e7f3759f993 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -44,7 +44,6 @@ async def test_islamic_prayer_times_sensors( ), freeze_time(NOW): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( hass.states.get(sensor_name).state == PRAYER_TIMES_TIMESTAMPS[key].astimezone(dt_util.UTC).isoformat() From 6a52283ce038a0a85f29cd7775b70784b50785bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 27 Sep 2023 20:30:32 +0200 Subject: [PATCH 006/968] Implement Airzone Cloud Aidoo climate support (#101011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Implement Airzone Cloud Aidoo climate support Signed-off-by: Álvaro Fernández Rojas * airzone_cloud: climate: add entity naming Signed-off-by: Álvaro Fernández Rojas --------- Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/climate.py | 56 +++++++++++- .../components/airzone_cloud/entity.py | 14 +++ .../components/airzone_cloud/test_climate.py | 89 +++++++++++++++++++ 3 files changed, 156 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 18393031ae3..af6c38b80b4 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -12,6 +12,7 @@ from aioairzone_cloud.const import ( API_UNITS, API_VALUE, AZD_ACTION, + AZD_AIDOOS, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, @@ -39,7 +40,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneEntity, AirzoneZoneEntity +from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { OperationAction.COOLING: HVACAction.COOLING, @@ -82,6 +83,16 @@ async def async_setup_entry( entities: list[AirzoneClimate] = [] + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + entities.append( + AirzoneAidooClimate( + coordinator, + aidoo_id, + aidoo_data, + ) + ) + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): entities.append( @@ -98,6 +109,7 @@ async def async_setup_entry( class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" + _attr_has_entity_name = True _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -156,11 +168,49 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) +class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneClimate): + """Define an Airzone Cloud Aidoo climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + aidoo_id: str, + aidoo_data: dict, + ) -> None: + """Initialize Airzone Cloud Aidoo climate.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = aidoo_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_MODE] = { + API_VALUE: mode.value, + } + params[API_POWER] = { + API_VALUE: True, + } + await self._async_update_params(params) + + class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): """Define an Airzone Cloud Zone climate.""" - _attr_has_entity_name = True - def __init__( self, coordinator: AirzoneUpdateCoordinator, diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 3214869aaab..b304f06d42b 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -74,6 +74,20 @@ class AirzoneAidooEntity(AirzoneEntity): value = aidoo.get(key) return value + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Aidoo parameters to Cloud API.""" + _LOGGER.debug("aidoo=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_aidoo_id_params( + self.aidoo_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneSystemEntity(AirzoneEntity): """Define an Airzone Cloud System entity.""" diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index acf1d082c29..fcd60f605ba 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -37,6 +37,25 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.0 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + # Zones state = hass.states.get("climate.dormitorio") assert state.state == HVACMode.OFF @@ -78,6 +97,23 @@ async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.bron", + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -117,6 +153,41 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.HEAT_COOL, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.HEAT_COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -205,6 +276,24 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Aidoos + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.bron", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.bron") + assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", From 7d07694496cf46471b49375ffe7ea6fe3f2c773c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 27 Sep 2023 16:34:25 -0500 Subject: [PATCH 007/968] Fix HomeKit handling of unavailable state (#101021) --- .../components/homekit/accessories.py | 4 ++- tests/components/homekit/test_type_covers.py | 6 +++-- tests/components/homekit/test_type_locks.py | 25 ++++++++++++++++++- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 88422b5c957..5a1e9bc1ea2 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -465,7 +465,9 @@ class HomeAccessory(Accessory): # type: ignore[misc] def async_update_state_callback(self, new_state: State | None) -> None: """Handle state change listener callback.""" _LOGGER.debug("New_state: %s", new_state) - if new_state is None: + # HomeKit handles unavailable state via the available property + # so we should not propagate it here + if new_state is None or new_state.state == STATE_UNAVAILABLE: return battery_state = None battery_charging_state = None diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 9da576b6a0e..b8841289611 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -74,17 +74,19 @@ async def test_garage_door_open_close(hass: HomeAssistant, hk_driver, events) -> assert acc.char_obstruction_detected.value is True hass.states.async_set( - entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: False} + entity_id, STATE_UNAVAILABLE, {ATTR_OBSTRUCTION_DETECTED: True} ) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN - assert acc.char_obstruction_detected.value is False + assert acc.char_obstruction_detected.value is True + assert acc.available is False hass.states.async_set(entity_id, STATE_UNKNOWN) await hass.async_block_till_done() assert acc.char_current_state.value == HK_DOOR_OPEN assert acc.char_target_state.value == HK_DOOR_OPEN + assert acc.available is True # Set from HomeKit call_close_cover = async_mock_service(hass, DOMAIN, "close_cover") diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index 32f1561644e..dc614ee54c4 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -68,10 +69,32 @@ async def test_lock_unlock(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 - hass.states.async_remove(entity_id) + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) await hass.async_block_till_done() assert acc.char_current_state.value == 3 assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is True + + # Unavailable should keep last state + # but set the accessory to not available + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + assert acc.available is False + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 # Set from HomeKit call_lock = async_mock_service(hass, DOMAIN, "lock") From b569cb61e9a75cf4e168836fa3b4e7ca8baa3ff2 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 27 Sep 2023 23:36:12 +0200 Subject: [PATCH 008/968] Adopt Hue integration to latest changes in Hue firmware (#101001) --- homeassistant/components/hue/manifest.json | 2 +- .../components/hue/v2/binary_sensor.py | 61 ++++++++-- homeassistant/components/hue/v2/sensor.py | 14 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/hue/fixtures/v2_resources.json | 108 ++++++++++++++++++ tests/components/hue/test_binary_sensor.py | 50 +++++++- tests/components/hue/test_sensor_v2.py | 2 - tests/components/hue/test_switch.py | 4 +- 9 files changed, 216 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index e55bd2782df..4cd6ca143cb 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -11,6 +11,6 @@ "iot_class": "local_push", "loggers": ["aiohue"], "quality_scale": "platinum", - "requirements": ["aiohue==4.6.2"], + "requirements": ["aiohue==4.7.0"], "zeroconf": ["_hue._tcp.local."] } diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 0a8f50b8b7a..1eded0429b8 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Hue binary sensors.""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import TypeAlias from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import ( @@ -9,9 +9,17 @@ from aiohue.v2.controllers.config import ( EntertainmentConfigurationController, ) from aiohue.v2.controllers.events import EventType -from aiohue.v2.controllers.sensors import MotionController +from aiohue.v2.controllers.sensors import ( + CameraMotionController, + ContactController, + MotionController, + TamperController, +) +from aiohue.v2.models.camera_motion import CameraMotion +from aiohue.v2.models.contact import Contact, ContactState from aiohue.v2.models.entertainment_configuration import EntertainmentStatus from aiohue.v2.models.motion import Motion +from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -25,8 +33,16 @@ from ..bridge import HueBridge from ..const import DOMAIN from .entity import HueBaseEntity -SensorType: TypeAlias = Motion | EntertainmentConfiguration -ControllerType: TypeAlias = MotionController | EntertainmentConfigurationController +SensorType: TypeAlias = ( + CameraMotion | Contact | Motion | EntertainmentConfiguration | Tamper +) +ControllerType: TypeAlias = ( + CameraMotionController + | ContactController + | MotionController + | EntertainmentConfigurationController + | TamperController +) async def async_setup_entry( @@ -57,8 +73,11 @@ async def async_setup_entry( ) # setup for each binary-sensor-type hue resource + register_items(api.sensors.camera_motion, HueMotionSensor) register_items(api.sensors.motion, HueMotionSensor) register_items(api.config.entertainment_configuration, HueEntertainmentActiveSensor) + register_items(api.sensors.contact, HueContactSensor) + register_items(api.sensors.tamper, HueTamperSensor) class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): @@ -87,12 +106,7 @@ class HueMotionSensor(HueBinarySensorBase): if not self.resource.enabled: # Force None (unknown) if the sensor is set to disabled in Hue return None - return self.resource.motion.motion - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"motion_valid": self.resource.motion.motion_valid} + return self.resource.motion.value class HueEntertainmentActiveSensor(HueBinarySensorBase): @@ -110,3 +124,30 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase): """Return sensor name.""" type_title = self.resource.type.value.replace("_", " ").title() return f"{self.resource.metadata.name}: {type_title}" + + +class HueContactSensor(HueBinarySensorBase): + """Representation of a Hue Contact sensor.""" + + _attr_device_class = BinarySensorDeviceClass.OPENING + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.enabled: + # Force None (unknown) if the sensor is set to disabled in Hue + return None + return self.resource.contact_report.state != ContactState.CONTACT + + +class HueTamperSensor(HueBinarySensorBase): + """Representation of a Hue Tamper sensor.""" + + _attr_device_class = BinarySensorDeviceClass.TAMPER + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + if not self.resource.tamper_reports: + return False + return self.resource.tamper_reports[0].state == TamperState.TAMPERED diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index cc36edb88b2..4bfb727b917 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -100,12 +100,7 @@ class HueTemperatureSensor(HueSensorBase): @property def native_value(self) -> float: """Return the value reported by the sensor.""" - return round(self.resource.temperature.temperature, 1) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return the optional state attributes.""" - return {"temperature_valid": self.resource.temperature.temperature_valid} + return round(self.resource.temperature.value, 1) class HueLightLevelSensor(HueSensorBase): @@ -122,14 +117,13 @@ class HueLightLevelSensor(HueSensorBase): # scale used because the human eye adjusts to light levels and small # changes at low lux levels are more noticeable than at high lux # levels. - return int(10 ** ((self.resource.light.light_level - 1) / 10000)) + return int(10 ** ((self.resource.light.value - 1) / 10000)) @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" return { - "light_level": self.resource.light.light_level, - "light_level_valid": self.resource.light.light_level_valid, + "light_level": self.resource.light.value, } @@ -149,6 +143,8 @@ class HueBatterySensor(HueSensorBase): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" + if self.resource.power_state.battery_state is None: + return {} return {"battery_state": self.resource.power_state.battery_state.value} diff --git a/requirements_all.txt b/requirements_all.txt index 4bb5909b09c..d9dc9f6439b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -256,7 +256,7 @@ aiohomekit==3.0.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4829c59a61..76b6ca777ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -234,7 +234,7 @@ aiohomekit==3.0.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.6.2 +aiohue==4.7.0 # homeassistant.components.imap aioimaplib==1.0.1 diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 371975e12a5..24f433f539c 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2221,5 +2221,113 @@ "id": "52612630-841e-4d39-9763-60346a0da759", "is_configured": true, "type": "geolocation" + }, + { + "id": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "product_data": { + "model_id": "SOC001", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Hue secure contact sensor", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "2.67.9", + "hardware_platform_type": "100b-125" + }, + "metadata": { + "name": "Test contact sensor", + "archetype": "unknown_archetype" + }, + "identify": {}, + "services": [ + { + "rid": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "rtype": "contact" + }, + { + "rid": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "rtype": "tamper" + } + ], + "type": "device" + }, + { + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "enabled": true, + "contact_report": { + "changed": "2023-09-27T10:01:36.968Z", + "state": "contact" + }, + "type": "contact" + }, + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "owner": { + "rid": "0240be0e-8b79-4a53-b9bb-b17fa14d7e75", + "rtype": "device" + }, + "tamper_reports": [ + { + "changed": "2023-09-25T10:02:08.774Z", + "source": "battery_door", + "state": "not_tampered" + } + ], + "type": "tamper" + }, + { + "id": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "id_v1": "/sensors/249", + "product_data": { + "model_id": "CAMERA", + "manufacturer_name": "Signify Netherlands B.V.", + "product_name": "Fake Hue Test Camera", + "product_archetype": "unknown_archetype", + "certified": true, + "software_version": "0.0.0", + "hardware_platform_type": "0" + }, + "metadata": { + "name": "Test Camera", + "archetype": "unknown_archetype" + }, + "identify": {}, + "usertest": { + "status": "set", + "usertest": false + }, + "services": [ + { + "rid": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "rtype": "camera_motion" + } + ], + "type": "device" + }, + { + "id": "d9f2cfee-5879-426b-aa1f-553af8f38176", + "id_v1": "/sensors/249", + "owner": { + "rid": "1cbda90c-b675-46b0-9e97-278e7e7857ed", + "rtype": "device" + }, + "enabled": true, + "motion": { + "motion": true, + "motion_valid": true, + "motion_report": { + "changed": "2023-09-27T10:06:41.822Z", + "motion": true + } + }, + "sensitivity": { + "status": "set", + "sensitivity": 2, + "sensitivity_max": 4 + }, + "type": "motion" } ] diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 7750f4a6795..3846f17aa76 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -14,8 +14,8 @@ async def test_binary_sensors( await setup_platform(hass, mock_bridge_v2, "binary_sensor") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 binary_sensors should be created from test data - assert len(hass.states.async_all()) == 2 + # 5 binary_sensors should be created from test data + assert len(hass.states.async_all()) == 5 # test motion sensor sensor = hass.states.get("binary_sensor.hue_motion_sensor_motion") @@ -23,7 +23,6 @@ async def test_binary_sensors( assert sensor.state == "off" assert sensor.name == "Hue motion sensor Motion" assert sensor.attributes["device_class"] == "motion" - assert sensor.attributes["motion_valid"] is True # test entertainment room active sensor sensor = hass.states.get( @@ -34,6 +33,51 @@ async def test_binary_sensors( assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" assert sensor.attributes["device_class"] == "running" + # test contact sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Contact" + assert sensor.attributes["device_class"] == "opening" + # test contact sensor disabled == state unknown + mock_bridge_v2.api.emit_event( + "update", + { + "enabled": False, + "id": "18802b4a-b2f6-45dc-8813-99cde47f3a4a", + "type": "contact", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + assert sensor.state == "unknown" + + # test tamper sensor + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor is not None + assert sensor.state == "off" + assert sensor.name == "Test contact sensor Tamper" + assert sensor.attributes["device_class"] == "tamper" + # test tamper sensor when no tamper reports exist + mock_bridge_v2.api.emit_event( + "update", + { + "id": "d7fcfab0-69e1-4afb-99df-6ed505211db4", + "tamper_reports": [], + "type": "tamper", + }, + ) + await hass.async_block_till_done() + sensor = hass.states.get("binary_sensor.test_contact_sensor_tamper") + assert sensor.state == "off" + + # test camera_motion sensor + sensor = hass.states.get("binary_sensor.test_camera_motion") + assert sensor is not None + assert sensor.state == "on" + assert sensor.name == "Test Camera Motion" + assert sensor.attributes["device_class"] == "motion" + async def test_binary_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: """Test if binary_sensor get added/updated from events.""" diff --git a/tests/components/hue/test_sensor_v2.py b/tests/components/hue/test_sensor_v2.py index 91eccc2c984..45e39e94119 100644 --- a/tests/components/hue/test_sensor_v2.py +++ b/tests/components/hue/test_sensor_v2.py @@ -28,7 +28,6 @@ async def test_sensors( assert sensor.attributes["device_class"] == "temperature" assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "°C" - assert sensor.attributes["temperature_valid"] is True # test illuminance sensor sensor = hass.states.get("sensor.hue_motion_sensor_illuminance") @@ -39,7 +38,6 @@ async def test_sensors( assert sensor.attributes["state_class"] == "measurement" assert sensor.attributes["unit_of_measurement"] == "lx" assert sensor.attributes["light_level"] == 18027 - assert sensor.attributes["light_level_valid"] is True # test battery sensor sensor = hass.states.get("sensor.wall_switch_with_2_controls_battery") diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index c8fa417b12c..a576b88a7c3 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,8 +14,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 2 entities should be created from test data - assert len(hass.states.async_all()) == 2 + # 3 entities should be created from test data + assert len(hass.states.async_all()) == 3 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion") From 38984dd93997ce23d47bf19a7f68f20794e7d129 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 28 Sep 2023 00:58:30 +0200 Subject: [PATCH 009/968] Update pyweatherflowudp to 1.4.3 (#101022) --- homeassistant/components/weatherflow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json index e2671d74cda..3c34250652d 100644 --- a/homeassistant/components/weatherflow/manifest.json +++ b/homeassistant/components/weatherflow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyweatherflowudp"], - "requirements": ["pyweatherflowudp==1.4.2"] + "requirements": ["pyweatherflowudp==1.4.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index d9dc9f6439b..5fcf0dc6fd6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2249,7 +2249,7 @@ pyvolumio==0.1.5 pywaze==0.5.0 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.2 +pyweatherflowudp==1.4.3 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76b6ca777ac..75446892ece 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ pyvolumio==0.1.5 pywaze==0.5.0 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.2 +pyweatherflowudp==1.4.3 # homeassistant.components.html5 pywebpush==1.9.2 From 5fe61ca5e74d13e793aeb0b98ae670443e23486e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 27 Sep 2023 19:55:26 -0500 Subject: [PATCH 010/968] Use webrtc-noise-gain without AVX2 (#101028) --- homeassistant/components/assist_pipeline/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/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index 138f880526d..db6c517a81a 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtc-noise-gain==1.2.1"] + "requirements": ["webrtc-noise-gain==1.2.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 698960095ba..bf287f564cc 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 yarl==1.9.2 zeroconf==0.115.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5fcf0dc6fd6..098e57ea5ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75446892ece..bd708f0c767 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.1 +webrtc-noise-gain==1.2.2 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 From dc1d3f727b25c49f93bd26344af891c504a560b2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 Sep 2023 02:59:19 +0200 Subject: [PATCH 011/968] Fix handling reload with invalid mqtt config (#101015) Fix handling reload whith invalid mqtt config --- homeassistant/components/mqtt/__init__.py | 13 +++++-- tests/components/mqtt/test_init.py | 41 +++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 5b5c39e6831..7caeb2b51f7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback -from homeassistant.exceptions import TemplateError, Unauthorized +from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -364,8 +364,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _reload_config(call: ServiceCall) -> None: """Reload the platforms.""" - # Fetch updated manual configured items and validate - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + # Fetch updated manually configured items and validate + if ( + config_yaml := await async_integration_yaml_config(hass, DOMAIN) + ) is None: + # Raise in case we have an invalid configuration + raise HomeAssistantError( + "Error reloading manually configured MQTT items, " + "check your configuration.yaml" + ) mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index e3a12a2c24e..48d949ae927 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3898,3 +3898,44 @@ async def test_reload_config_entry( assert state.state == "manual2_update_after_reload" assert (state := hass.states.get("sensor.test_manual3")) is not None assert state.state is STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an invalid config and assert again + invalid_config = {"mqtt": "some_invalid_config"} + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test nothing changed as loading the config failed + assert hass.states.get("sensor.test") is not None From 4f1906ae3e5f83992b0ef5b2bac6d81b8f8c4f74 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Sep 2023 03:00:14 +0200 Subject: [PATCH 012/968] Remove myself from cpuspeed codeowners (#101020) --- CODEOWNERS | 4 ++-- homeassistant/components/cpuspeed/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index eed0f633df3..661d21fd95c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -233,8 +233,8 @@ build.json @home-assistant/supervisor /tests/components/counter/ @fabaff /homeassistant/components/cover/ @home-assistant/core /tests/components/cover/ @home-assistant/core -/homeassistant/components/cpuspeed/ @fabaff @frenck -/tests/components/cpuspeed/ @fabaff @frenck +/homeassistant/components/cpuspeed/ @fabaff +/tests/components/cpuspeed/ @fabaff /homeassistant/components/crownstone/ @Crownstone @RicArch97 /tests/components/crownstone/ @Crownstone @RicArch97 /homeassistant/components/cups/ @fabaff diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index a53c34fb0de..988dff6c6c9 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -1,7 +1,7 @@ { "domain": "cpuspeed", "name": "CPU Speed", - "codeowners": ["@fabaff", "@frenck"], + "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cpuspeed", "integration_type": "device", From f757d4c7da9ead2feba0341851bda2742fe38502 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 28 Sep 2023 03:02:47 +0200 Subject: [PATCH 013/968] Update py-cpuinfo to 9.0.0 (#101019) --- homeassistant/components/cpuspeed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json index 988dff6c6c9..ff3a41d9c09 100644 --- a/homeassistant/components/cpuspeed/manifest.json +++ b/homeassistant/components/cpuspeed/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/cpuspeed", "integration_type": "device", "iot_class": "local_push", - "requirements": ["py-cpuinfo==8.0.0"] + "requirements": ["py-cpuinfo==9.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 098e57ea5ee..0fc8675bdbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1499,7 +1499,7 @@ pvo==1.0.0 py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd708f0c767..36529f398e7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1144,7 +1144,7 @@ pvo==1.0.0 py-canary==0.5.3 # homeassistant.components.cpuspeed -py-cpuinfo==8.0.0 +py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 From d41144ee880d98da5527a63b281abe48805c2918 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:39:57 +1300 Subject: [PATCH 014/968] ESPHome: dont send error when wake word is aborted (#101032) * ESPHome dont send error when wake word is aborted * Add test --- .../components/esphome/voice_assistant.py | 8 +++-- .../esphome/test_voice_assistant.py | 29 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 58f9ce5abf4..baf3a9011e9 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -24,7 +24,10 @@ from homeassistant.components.assist_pipeline import ( async_pipeline_from_audio_stream, select as pipeline_select, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -273,6 +276,8 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): }, ) _LOGGER.warning("Pipeline not found") + except WakeWordDetectionAborted: + pass # Wake word detection was aborted and `handle_finished` is enough. except WakeWordDetectionError as e: self.handle_event( VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, @@ -281,7 +286,6 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "message": e.message, }, ) - _LOGGER.warning("No Wake word provider found") finally: self.handle_finished() diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index 6c54c5f62f3..9b6bcf1c6c7 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -12,7 +12,10 @@ from homeassistant.components.assist_pipeline import ( PipelineEventType, PipelineStage, ) -from homeassistant.components.assist_pipeline.error import WakeWordDetectionError +from homeassistant.components.assist_pipeline.error import ( + WakeWordDetectionAborted, + WakeWordDetectionError, +) from homeassistant.components.esphome import DomainData from homeassistant.components.esphome.voice_assistant import VoiceAssistantUDPServer from homeassistant.core import HomeAssistant @@ -411,3 +414,27 @@ async def test_wake_word_exception( conversation_id=None, flags=2, ) + + +async def test_wake_word_abort_exception( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test that the pipeline is set to start with Wake word.""" + + async def async_pipeline_from_audio_stream(*args, **kwargs): + raise WakeWordDetectionAborted + + with patch( + "homeassistant.components.esphome.voice_assistant.async_pipeline_from_audio_stream", + new=async_pipeline_from_audio_stream, + ), patch.object(voice_assistant_udp_server_v2, "handle_event") as mock_handle_event: + voice_assistant_udp_server_v2.transport = Mock() + + await voice_assistant_udp_server_v2.run_pipeline( + device_id="mock-device-id", + conversation_id=None, + flags=2, + ) + + mock_handle_event.assert_not_called() From e1771ae01e5873eba7199de54b856f53665ff497 Mon Sep 17 00:00:00 2001 From: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com> Date: Thu, 28 Sep 2023 08:05:00 +0200 Subject: [PATCH 015/968] Fix circular dependancy detection (#100458) * Fix _async_component_dependencies Fix bug with circular dependency detection Fix bug with circular after_dependency detection Simplify interface and make the code more readable * Implement review feedback * Pass all conflicting deps to Exception * Change inner docstring Co-authored-by: Erik Montnemery --------- Co-authored-by: Erik Montnemery --- homeassistant/loader.py | 57 +++++++++++++++++++---------------------- tests/test_loader.py | 51 +++++++++++++++++++++--------------- 2 files changed, 57 insertions(+), 51 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 9d4d6e880f8..a3ddbf4cbca 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -777,9 +777,7 @@ class Integration: return self._all_dependencies_resolved try: - dependencies = await _async_component_dependencies( - self.hass, self.domain, self, set(), set() - ) + dependencies = await _async_component_dependencies(self.hass, self) dependencies.discard(self.domain) self._all_dependencies = dependencies self._all_dependencies_resolved = True @@ -998,7 +996,7 @@ class IntegrationNotLoaded(LoaderError): class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" - def __init__(self, from_domain: str, to_domain: str) -> None: + def __init__(self, from_domain: str | set[str], to_domain: str) -> None: """Initialize circular dependency error.""" super().__init__(f"Circular dependency detected: {from_domain} -> {to_domain}.") self.from_domain = from_domain @@ -1132,43 +1130,40 @@ def bind_hass(func: _CallableT) -> _CallableT: async def _async_component_dependencies( hass: HomeAssistant, - start_domain: str, integration: Integration, - loaded: set[str], - loading: set[str], ) -> set[str]: - """Recursive function to get component dependencies. + """Get component dependencies.""" + loading = set() + loaded = set() - Async friendly. - """ - domain = integration.domain - loading.add(domain) + async def component_dependencies_impl(integration: Integration) -> None: + """Recursively get component dependencies.""" + domain = integration.domain + loading.add(domain) - for dependency_domain in integration.dependencies: - # Check not already loaded - if dependency_domain in loaded: - continue + for dependency_domain in integration.dependencies: + dep_integration = await async_get_integration(hass, dependency_domain) - # If we are already loading it, we have a circular dependency. - if dependency_domain in loading: - raise CircularDependency(domain, dependency_domain) + # If we are already loading it, we have a circular dependency. + # We have to check it here to make sure that every integration that + # depends on us, does not appear in our own after_dependencies. + if conflict := loading.intersection(dep_integration.after_dependencies): + raise CircularDependency(conflict, dependency_domain) - loaded.add(dependency_domain) + # If we have already loaded it, no point doing it again. + if dependency_domain in loaded: + continue - dep_integration = await async_get_integration(hass, dependency_domain) + # If we are already loading it, we have a circular dependency. + if dependency_domain in loading: + raise CircularDependency(dependency_domain, domain) - if start_domain in dep_integration.after_dependencies: - raise CircularDependency(start_domain, dependency_domain) + await component_dependencies_impl(dep_integration) - if dep_integration.dependencies: - dep_loaded = await _async_component_dependencies( - hass, start_domain, dep_integration, loaded, loading - ) + loading.remove(domain) + loaded.add(domain) - loaded.update(dep_loaded) - - loaded.add(domain) - loading.remove(domain) + await component_dependencies_impl(integration) return loaded diff --git a/tests/test_loader.py b/tests/test_loader.py index b62e25b79e3..4a03a7379b0 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -11,35 +11,46 @@ from homeassistant.core import HomeAssistant, callback from .common import MockModule, async_get_persistent_notifications, mock_integration -async def test_component_dependencies(hass: HomeAssistant) -> None: - """Test if we can get the proper load order of components.""" +async def test_circular_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect circular dependencies of components.""" mock_integration(hass, MockModule("mod1")) mock_integration(hass, MockModule("mod2", ["mod1"])) - mod_3 = mock_integration(hass, MockModule("mod3", ["mod2"])) + mock_integration(hass, MockModule("mod3", ["mod1"])) + mod_4 = mock_integration(hass, MockModule("mod4", ["mod2", "mod3"])) - assert {"mod1", "mod2", "mod3"} == await loader._async_component_dependencies( - hass, "mod_3", mod_3, set(), set() - ) + deps = await loader._async_component_dependencies(hass, mod_4) + assert deps == {"mod1", "mod2", "mod3", "mod4"} - # Create circular dependency + # Create a circular dependency + mock_integration(hass, MockModule("mod1", ["mod4"])) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) + + # Create a different circular dependency mock_integration(hass, MockModule("mod1", ["mod3"])) - with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) - # Depend on non-existing component - mod_1 = mock_integration(hass, MockModule("mod1", ["nonexisting"])) - - with pytest.raises(loader.IntegrationNotFound): - await loader._async_component_dependencies(hass, "mod_1", mod_1, set(), set()) - - # Having an after dependency 2 deps down that is circular - mod_1 = mock_integration( - hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod_3"]}) + # Create a circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod4"]}) ) - with pytest.raises(loader.CircularDependency): - await loader._async_component_dependencies(hass, "mod_3", mod_3, set(), set()) + await loader._async_component_dependencies(hass, mod_4) + + # Create a different circular after_dependency + mock_integration( + hass, MockModule("mod1", partial_manifest={"after_dependencies": ["mod3"]}) + ) + with pytest.raises(loader.CircularDependency): + await loader._async_component_dependencies(hass, mod_4) + + +async def test_nonexistent_component_dependencies(hass: HomeAssistant) -> None: + """Test if we can detect nonexistent dependencies of components.""" + mod_1 = mock_integration(hass, MockModule("mod1", ["nonexistent"])) + with pytest.raises(loader.IntegrationNotFound): + await loader._async_component_dependencies(hass, mod_1) def test_component_loader(hass: HomeAssistant) -> None: From d70cb8caa5b935f40ae664dfd615e455d1c9c4f7 Mon Sep 17 00:00:00 2001 From: tyjtyj Date: Thu, 28 Sep 2023 14:08:07 +0800 Subject: [PATCH 016/968] Fix google maps device_tracker same last seen timestamp (#99963) * Update device_tracker.py This fix the google_maps does not show current location when HA started/restarted and also fix unnecessary update when last_seen timestamp is the same. Unnecessary update is causing proximity sensor switching from between stationary and certain direction. * Remove elif * Fix Black check * fix black check * Update device_tracker.py Better patch * Update device_tracker.py * Update device_tracker.py Fix Black * Update device_tracker.py change warning to debug --- .../components/google_maps/device_tracker.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 2ee12f0154c..be776df1751 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -112,12 +112,22 @@ class GoogleMapsScanner: last_seen = dt_util.as_utc(person.datetime) if last_seen < self._prev_seen.get(dev_id, last_seen): - _LOGGER.warning( + _LOGGER.debug( "Ignoring %s update because timestamp is older than last timestamp", person.nickname, ) _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue + if last_seen == self._prev_seen.get(dev_id, last_seen) and hasattr( + self, "success_init" + ): + _LOGGER.debug( + "Ignoring %s update because timestamp " + "is the same as the last timestamp %s", + person.nickname, + last_seen, + ) + continue self._prev_seen[dev_id] = last_seen attrs = { From 089f87c45b2850942fc88a00f67cb3385315207d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 09:13:33 +0200 Subject: [PATCH 017/968] Fix onvif creating a new entity for every new event (#101035) Use topic value as topic --- homeassistant/components/onvif/parsers.py | 224 ++++++++++++---------- 1 file changed, 125 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 3f405767c54..6185adb70a1 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -48,15 +48,16 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -71,15 +72,16 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -95,15 +97,16 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -119,15 +122,16 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -143,15 +147,16 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -167,8 +172,9 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,12 +183,12 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{topic_value}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -198,8 +204,9 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +215,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -230,8 +237,9 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,12 +248,12 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -261,8 +269,9 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,12 +280,12 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value in ["1", "true"], + message_value.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @@ -292,8 +301,9 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +312,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -322,18 +332,19 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -347,18 +358,19 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -372,18 +384,19 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -397,18 +410,19 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -422,18 +436,19 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{value_1}_{video_source}", + f"{uid}_{topic_value}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -446,15 +461,16 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Digital Input", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -467,15 +483,16 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Relay Triggered", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "active", + message_value.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @@ -488,15 +505,16 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Storage Failure", "binary_sensor", "problem", None, - value_1.Data.SimpleItem[0].Value == "true", + message_value.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -510,13 +528,14 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - usage = float(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + usage = float(message_value.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Processor Usage", "sensor", None, @@ -535,10 +554,11 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Reboot", "sensor", "timestamp", @@ -557,10 +577,11 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Reset", "sensor", "timestamp", @@ -581,10 +602,11 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Backup", "sensor", "timestamp", @@ -604,10 +626,11 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) return Event( - f"{uid}_{value_1}", + f"{uid}_{topic_value}", "Last Clock Synchronization", "sensor", "timestamp", @@ -628,15 +651,16 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - value_1 = msg.Message._value_1 # pylint: disable=protected-access - source = value_1.Source.SimpleItem[0].Value + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + source = message_value.Source.SimpleItem[0].Value return Event( - f"{uid}_{value_1}_{source}", + f"{uid}_{topic_value}_{source}", "Recording Job State", "binary_sensor", None, None, - value_1.Data.SimpleItem[0].Value == "Active", + message_value.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -653,8 +677,9 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -663,12 +688,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -685,8 +710,9 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - value_1 = msg.Message._value_1 # pylint: disable=protected-access - for source in value_1.Source.SimpleItem: + message_value = msg.Message._value_1 # pylint: disable=protected-access + topic_value = msg.Topic._value_1 # pylint: disable=protected-access + for source in message_value.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -695,12 +721,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - value_1.Data.SimpleItem[0].Value, + message_value.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): From 3db7bdc6307fd7297fd0cce8ca9eead62c38e2d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 28 Sep 2023 09:26:13 +0200 Subject: [PATCH 018/968] Implement Airzone Cloud Group climate support (#101018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/climate.py | 87 ++++++++++++- .../components/airzone_cloud/entity.py | 43 ++++++ .../components/airzone_cloud/test_climate.py | 122 ++++++++++++++++++ 3 files changed, 251 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index af6c38b80b4..a86440bad20 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -7,16 +7,19 @@ from aioairzone_cloud.common import OperationAction, OperationMode, TemperatureU from aioairzone_cloud.const import ( API_MODE, API_OPTS, + API_PARAMS, API_POWER, API_SETPOINT, API_UNITS, API_VALUE, AZD_ACTION, AZD_AIDOOS, + AZD_GROUPS, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, AZD_MODES, + AZD_NUM_DEVICES, AZD_POWER, AZD_TEMP, AZD_TEMP_SET, @@ -40,7 +43,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AirzoneUpdateCoordinator -from .entity import AirzoneAidooEntity, AirzoneEntity, AirzoneZoneEntity +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneGroupEntity, + AirzoneZoneEntity, +) HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { OperationAction.COOLING: HVACAction.COOLING, @@ -93,6 +101,17 @@ async def async_setup_entry( ) ) + # Groups + for group_id, group_data in coordinator.data.get(AZD_GROUPS, {}).items(): + if group_data[AZD_NUM_DEVICES] > 1: + entities.append( + AirzoneGroupClimate( + coordinator, + group_id, + group_data, + ) + ) + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): entities.append( @@ -208,6 +227,72 @@ class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneClimate): await self._async_update_params(params) +class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneClimate): + """Define an Airzone Cloud Group climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict, + ) -> None: + """Initialize Airzone Cloud Group climate.""" + super().__init__(coordinator, group_id, group_data) + + self._attr_unique_id = group_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + params = { + API_PARAMS: { + API_POWER: True, + }, + } + await self._async_update_params(params) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + params = { + API_PARAMS: { + API_POWER: False, + }, + } + await self._async_update_params(params) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + params: dict[str, Any] = {} + if ATTR_TEMPERATURE in kwargs: + params[API_PARAMS] = { + API_SETPOINT: kwargs[ATTR_TEMPERATURE], + } + params[API_OPTS] = { + API_UNITS: TemperatureUnit.CELSIUS.value, + } + await self._async_update_params(params) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = { + API_PARAMS: {}, + } + if hvac_mode == HVACMode.OFF: + params[API_PARAMS][API_POWER] = False + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_PARAMS][API_MODE] = mode.value + params[API_PARAMS][API_POWER] = True + await self._async_update_params(params) + + class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): """Define an Airzone Cloud Zone climate.""" diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index b304f06d42b..749d4615e65 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -9,6 +9,7 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_AVAILABLE, AZD_FIRMWARE, + AZD_GROUPS, AZD_NAME, AZD_SYSTEM_ID, AZD_SYSTEMS, @@ -89,6 +90,48 @@ class AirzoneAidooEntity(AirzoneEntity): self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) +class AirzoneGroupEntity(AirzoneEntity): + """Define an Airzone Cloud Group entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.group_id = group_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, group_id)}, + manufacturer=MANUFACTURER, + name=group_data[AZD_NAME], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Group value by key.""" + value = None + if group := self.coordinator.data[AZD_GROUPS].get(self.group_id): + value = group.get(key) + return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Group parameters to Cloud API.""" + _LOGGER.debug("group=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_group_id_params( + self.group_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + + class AirzoneSystemEntity(AirzoneEntity): """Define an Airzone Cloud System entity.""" diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index fcd60f605ba..56c563a8680 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -56,6 +56,24 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + # Groups + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 27 + assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.5 + assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING + assert state.attributes.get(ATTR_HVAC_MODES) == [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes.get(ATTR_MAX_TEMP) == 30 + assert state.attributes.get(ATTR_MIN_TEMP) == 15 + assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + # Zones state = hass.states.get("climate.dormitorio") assert state.state == HVACMode.OFF @@ -114,6 +132,39 @@ async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: state = hass.states.get("climate.bron") assert state.state == HVACMode.HEAT + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.group", + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -188,6 +239,41 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: state = hass.states.get("climate.bron") assert state.state == HVACMode.OFF + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.DRY + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -252,6 +338,24 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: await async_init_integration(hass) + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -294,6 +398,24 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: state = hass.states.get("climate.bron") assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + # Groups + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_group", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.group", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.group") + assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", From 4a73ccb7db79634e6217be1ea201417c8ca74ed1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 10:17:07 +0200 Subject: [PATCH 019/968] Create function to extract onvif message (#101036) Create extract message function --- homeassistant/components/onvif/parsers.py | 229 ++++++++++------------ 1 file changed, 104 insertions(+), 125 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 6185adb70a1..02899dbc1a2 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -20,6 +20,11 @@ VIDEO_SOURCE_MAPPING = { } +def extract_message(msg: Any) -> tuple[str, Any]: + """Extract the message content and the topic.""" + return msg.Topic._value_1, msg.Message._value_1 # pylint: disable=protected-access + + def _normalize_video_source(source: str) -> str: """Normalize video source. @@ -48,16 +53,15 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/MotionAlarm """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -72,16 +76,15 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -97,16 +100,15 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooDark/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -122,16 +124,15 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/ImageTooBright/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -147,16 +148,15 @@ async def async_parse_scene_change(uid: str, msg) -> Event | None: Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -172,9 +172,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -183,12 +182,12 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -204,9 +203,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -215,12 +213,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -237,9 +235,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -248,12 +245,12 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -269,9 +266,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -280,12 +276,12 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value in ["1", "true"], + payload.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @@ -301,9 +297,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -312,12 +307,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -332,19 +327,18 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -358,19 +352,18 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -384,19 +377,18 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -410,19 +402,18 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -436,19 +427,18 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{topic_value}_{video_source}", + f"{uid}_{topic}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -461,16 +451,15 @@ async def async_parse_digital_input(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/DigitalInput """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Digital Input", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -483,16 +472,15 @@ async def async_parse_relay(uid: str, msg) -> Event | None: Topic: tns1:Device/Trigger/Relay """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Relay Triggered", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "active", + payload.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @@ -505,16 +493,15 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Storage Failure", "binary_sensor", "problem", None, - message_value.Data.SimpleItem[0].Value == "true", + payload.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -528,14 +515,13 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/ProcessorUsage """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - usage = float(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + usage = float(payload.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Processor Usage", "sensor", None, @@ -554,11 +540,10 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Reboot", "sensor", "timestamp", @@ -577,11 +562,10 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Reset", "sensor", "timestamp", @@ -602,11 +586,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Backup", "sensor", "timestamp", @@ -626,11 +609,10 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - date_time = local_datetime_or_none(message_value.Data.SimpleItem[0].Value) + topic, payload = extract_message(msg) + date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value) return Event( - f"{uid}_{topic_value}", + f"{uid}_{topic}", "Last Clock Synchronization", "sensor", "timestamp", @@ -651,16 +633,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - source = message_value.Source.SimpleItem[0].Value + topic, payload = extract_message(msg) + source = payload.Source.SimpleItem[0].Value return Event( - f"{uid}_{topic_value}_{source}", + f"{uid}_{topic}_{source}", "Recording Job State", "binary_sensor", None, None, - message_value.Data.SimpleItem[0].Value == "Active", + payload.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -677,9 +658,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -688,12 +668,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - message_value.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -710,9 +690,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - message_value = msg.Message._value_1 # pylint: disable=protected-access - topic_value = msg.Topic._value_1 # pylint: disable=protected-access - for source in message_value.Source.SimpleItem: + topic, payload = extract_message(msg) + for source in payload.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -721,12 +700,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{topic_value}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - message_value.Data.SimpleItem[0].Value, + payload.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): From d1f1bdebde65222a8426d37850d8b671980f8ae5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 10:55:48 +0200 Subject: [PATCH 020/968] Add feature to add measuring station via number in waqi (#99992) * Add feature to add measuring station via number * Add feature to add measuring station via number * Add feature to add measuring station via number --- homeassistant/components/waqi/config_flow.py | 123 ++++++++++-- homeassistant/components/waqi/strings.json | 22 ++- tests/components/waqi/test_config_flow.py | 191 +++++++++++++++++-- 3 files changed, 301 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index b5f3a18b223..8404b425678 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,6 +1,7 @@ """Config flow for World Air Quality Index (WAQI) integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable import logging from typing import Any @@ -18,25 +19,36 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, CONF_NAME, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.selector import LocationSelector +from homeassistant.helpers.selector import ( + LocationSelector, + SelectSelector, + SelectSelectorConfig, +) from homeassistant.helpers.typing import ConfigType from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER _LOGGER = logging.getLogger(__name__) +CONF_MAP = "map" + class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for World Air Quality Index (WAQI).""" VERSION = 1 + def __init__(self) -> None: + """Initialize config flow.""" + self.data: dict[str, Any] = {} + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -47,13 +59,8 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): session=async_get_clientsession(self.hass) ) as waqi_client: waqi_client.authenticate(user_input[CONF_API_KEY]) - location = user_input[CONF_LOCATION] try: - measuring_station: WAQIAirQuality = ( - await waqi_client.get_by_coordinates( - location[CONF_LATITUDE], location[CONF_LONGITUDE] - ) - ) + await waqi_client.get_by_ip() except WAQIAuthenticationError: errors["base"] = "invalid_auth" except WAQIConnectionError: @@ -62,36 +69,110 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.exception(exc) errors["base"] = "unknown" else: - await self.async_set_unique_id(str(measuring_station.station_id)) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=measuring_station.city.name, - data={ - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_STATION_NUMBER: measuring_station.station_id, - }, - ) + self.data = user_input + if user_input[CONF_METHOD] == CONF_MAP: + return await self.async_step_map() + return await self.async_step_station_number() return self.async_show_form( step_id="user", - data_schema=self.add_suggested_values_to_schema( + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_METHOD): SelectSelector( + SelectSelectorConfig( + options=[CONF_MAP, CONF_STATION_NUMBER], + translation_key="method", + ) + ), + } + ), + errors=errors, + ) + + async def _async_base_step( + self, + step_id: str, + method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], + data_schema: vol.Schema, + user_input: dict[str, Any] | None = None, + ) -> FlowResult: + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await method(waqi_client, user_input) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=step_id, data_schema=data_schema, errors=errors + ) + + async def async_step_map( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via map.""" + return await self._async_base_step( + CONF_MAP, + lambda waqi_client, data: waqi_client.get_by_coordinates( + data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] + ), + self.add_suggested_values_to_schema( vol.Schema( { - vol.Required(CONF_API_KEY): str, vol.Required( CONF_LOCATION, ): LocationSelector(), } ), - user_input - or { + { CONF_LOCATION: { CONF_LATITUDE: self.hass.config.latitude, CONF_LONGITUDE: self.hass.config.longitude, } }, ), - errors=errors, + user_input, + ) + + async def async_step_station_number( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add measuring station via station number.""" + return await self._async_base_step( + CONF_STATION_NUMBER, + lambda waqi_client, data: waqi_client.get_by_station_number( + data[CONF_STATION_NUMBER] + ), + vol.Schema( + { + vol.Required( + CONF_STATION_NUMBER, + ): int, + } + ), + user_input, + ) + + async def _async_create_entry( + self, measuring_station: WAQIAirQuality + ) -> FlowResult: + await self.async_set_unique_id(str(measuring_station.station_id)) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=measuring_station.city.name, + data={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_STATION_NUMBER: measuring_station.station_id, + }, ) async def async_step_import(self, import_config: ConfigType) -> FlowResult: diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 4ceb911de9e..46031a3072b 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -2,10 +2,20 @@ "config": { "step": { "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "method": "How do you want to select a measuring station?" + } + }, + "map": { "description": "Select a location to get the closest measuring station.", "data": { - "location": "[%key:common::config_flow::data::location%]", - "api_key": "[%key:common::config_flow::data::api_key%]" + "location": "[%key:common::config_flow::data::location%]" + } + }, + "station_number": { + "data": { + "station_number": "Measuring station number" } } }, @@ -18,6 +28,14 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, + "selector": { + "method": { + "options": { + "map": "Select nearest from point on the map", + "station_number": "Enter a station number" + } + } + }, "issues": { "deprecated_yaml_import_issue_invalid_auth": { "title": "The World Air Quality Index YAML configuration import failed", diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index 3901ffad550..be738a119e5 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -1,17 +1,20 @@ """Test the World Air Quality Index (WAQI) config flow.""" import json +from typing import Any from unittest.mock import AsyncMock, patch from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError import pytest from homeassistant import config_entries +from homeassistant.components.waqi.config_flow import CONF_MAP from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, + CONF_METHOD, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -21,7 +24,29 @@ from tests.common import load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: +@pytest.mark.parametrize( + ("method", "payload"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + ), + ], +) +async def test_full_map_flow( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -31,17 +56,36 @@ async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No with patch( "aiowaqi.WAQIClient.authenticate", ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", + "aiowaqi.WAQIClient.get_by_ip", return_value=WAQIAirQuality.parse_obj( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, ) await hass.async_block_till_done() @@ -73,21 +117,35 @@ async def test_flow_errors( with patch( "aiowaqi.WAQIClient.authenticate", ), patch( - "aiowaqi.WAQIClient.get_by_coordinates", + "aiowaqi.WAQIClient.get_by_ip", side_effect=exception, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", - }, + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.FORM assert result["errors"] == {"base": error} + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: CONF_MAP}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "map" + with patch( "aiowaqi.WAQIClient.authenticate", ), patch( @@ -100,9 +158,118 @@ async def test_flow_errors( result["flow_id"], { CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, - CONF_API_KEY: "asd", }, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("method", "payload", "exception", "error"), + [ + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_MAP, + { + CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0}, + }, + Exception(), + "unknown", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + WAQIConnectionError(), + "cannot_connect", + ), + ( + CONF_STATION_NUMBER, + { + CONF_STATION_NUMBER: 4584, + }, + Exception(), + "unknown", + ), + ], +) +async def test_error_in_second_step( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + method: str, + payload: dict[str, Any], + exception: Exception, + error: str, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_ip", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_KEY: "asd", CONF_METHOD: method}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == method + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", side_effect=exception + ), patch("aiowaqi.WAQIClient.get_by_station_number", side_effect=exception): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + payload, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "de Jongweg, Utrecht" + assert result["data"] == { + CONF_API_KEY: "asd", + CONF_STATION_NUMBER: 4584, + } + assert len(mock_setup_entry.mock_calls) == 1 From b43262014fc656f7f9522b6d5d77b9f01af0dfc8 Mon Sep 17 00:00:00 2001 From: lennart24 <18117505+lennart24@users.noreply.github.com> Date: Thu, 28 Sep 2023 12:59:02 +0200 Subject: [PATCH 021/968] Add shutter_tilt support for Fibaro FGR 223 (#96283) * add support for shutter_tilt for Fibaro FGR 223 add tests for fgr 223 * Adjust comments and docstring --------- Co-authored-by: Lennart <18117505+Ced4@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 60 + tests/components/zwave_js/conftest.py | 14 + .../fixtures/cover_fibaro_fgr223_state.json | 2325 +++++++++++++++++ tests/components/zwave_js/test_cover.py | 138 +- 4 files changed, 2530 insertions(+), 7 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index d54dc659be1..0a3f61fd824 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -160,6 +160,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): writeable: bool | None = None # [optional] the value's states map must include ANY of these key/value pairs any_available_states: set[tuple[int, str]] | None = None + # [optional] the value's value must match this value + value: Any | None = None @dataclass @@ -378,6 +380,61 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Shutter Fibaro FGR223 + # Combine both switch_multilevel endpoints into shutter_tilt + # if operating mode (151) is set to venetian blind (2) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter_tilt", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={1}, + type={ValueType.NUMBER}, + ), + data_template=CoverTiltDataTemplate( + current_tilt_value_id=ZwaveValueID( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + target_tilt_value_id=ZwaveValueID( + property_=TARGET_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.CONFIGURATION}, + property={151}, + endpoint={0}, + value={2}, + ) + ], + ), + # Fibaro Shutter Fibaro FGR223 + # Disable endpoint 2 (slat), + # as these are either combined with endpoint one as shutter_tilt + # or it has no practical function. + # CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x010F}, + product_id={0x1000, 0x1001}, + product_type={0x0303}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) ZWaveDiscoverySchema( platform=Platform.COVER, @@ -1236,6 +1293,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: ) ): return False + # check value + if schema.value is not None and value.value not in schema.value: + return False return True diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index e950ff0402c..bbc836488c2 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -483,6 +483,12 @@ def fibaro_fgr222_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr222_state.json")) +@pytest.fixture(name="fibaro_fgr223_shutter_state", scope="session") +def fibaro_fgr223_shutter_state_fixture(): + """Load the Fibaro FGR223 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) + + @pytest.fixture(name="merten_507801_state", scope="session") def merten_507801_state_fixture(): """Load the Merten 507801 Shutter node state fixture data.""" @@ -1054,6 +1060,14 @@ def fibaro_fgr222_shutter_cover_fixture(client, fibaro_fgr222_shutter_state): return node +@pytest.fixture(name="fibaro_fgr223_shutter") +def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): + """Mock a Fibaro FGR223 Shutter node.""" + node = Node(client, copy.deepcopy(fibaro_fgr223_shutter_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state): """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json new file mode 100644 index 00000000000..b0f4992e319 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_fibaro_fgr223_state.json @@ -0,0 +1,2325 @@ +{ + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 271, + "productId": 4096, + "productType": 771, + "firmwareVersion": "5.1", + "zwavePlusVersion": 1, + "name": "fgr 223 test cover", + "location": "test location", + "deviceConfig": { + "filename": "/data/db/devices/0x010f/fgr223.json", + "isEmbedded": true, + "manufacturer": "Fibargroup", + "manufacturerId": 271, + "label": "FGR223", + "description": "Roller Shutter 3", + "devices": [ + { + "productType": 771, + "productId": 4096 + }, + { + "productType": 771, + "productId": 12288 + }, + { + "productType": 771, + "productId": 16384 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "proprietary": { + "fibaroCCs": [38] + } + }, + "label": "FGR223", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 10, + "index": 0, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 2, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 86, + "name": "CRC-16 Encapsulation", + "version": 1, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": true + } + ] + }, + { + "nodeId": 10, + "index": 1, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 3, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 10, + "index": 2, + "installerIcon": 6400, + "userIcon": 6400, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 2, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 108, + "commandClassName": "Supervision", + "property": "ccSupported", + "propertyKey": 91, + "propertyName": "ccSupported", + "propertyKeyName": "91", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Switch type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Switch type", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switches", + "1": "Toggle switches", + "2": "Single momentary switch (S1)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Switch type" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Inputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Inputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Outputs orientation", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Outputs orientation", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "default", + "1": "reversed" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Outputs orientation" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 1, + "propertyName": "S1 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 1 time", + "label": "S1 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S1 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 2, + "propertyName": "S1 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 2 times", + "label": "S1 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S1 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 4, + "propertyName": "S1 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is pressed 3 times", + "label": "S1 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S1 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyKey": 8, + "propertyName": "S1 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S1 is held down or released", + "label": "S1 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S1 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S1 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 1, + "propertyName": "S2 scenes: Pressed 1 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 1 time", + "label": "S2 scenes: Pressed 1 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 1 time", + "info": "Send a Central Scene notification when S2 is pressed 1 time" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 2, + "propertyName": "S2 scenes: Pressed 2 times", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 2 times", + "label": "S2 scenes: Pressed 2 times", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 2 times", + "info": "Send a Central Scene notification when S2 is pressed 2 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 4, + "propertyName": "S2 scenes: Pressed 3 time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is pressed 3 times", + "label": "S2 scenes: Pressed 3 time", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Pressed 3 time", + "info": "Send a Central Scene notification when S2 is pressed 3 times" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyKey": 8, + "propertyName": "S2 scenes: Hold down / Release", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Send a Central Scene notification when S2 is held down or released", + "label": "S2 scenes: Hold down / Release", + "default": 0, + "min": 0, + "max": 1, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "S2 scenes: Hold down / Release", + "info": "Send a Central Scene notification when S2 is held down or released" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 60, + "propertyName": "Measuring power consumed by the device itself", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Measuring power consumed by the device itself", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Function inactive", + "1": "Function active" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Measuring power consumed by the device itself" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 61, + "propertyName": "Power reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - on change", + "default": 15, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - on change" + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 62, + "propertyName": "Power reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Power reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 65, + "propertyName": "Energy reports - on change", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - on change", + "default": 10, + "min": 0, + "max": 500, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - on change" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 66, + "propertyName": "Energy reports - periodic", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Energy reports - periodic", + "default": 3600, + "min": 0, + "max": 32400, + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Energy reports - periodic" + }, + "value": 3600 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 150, + "propertyName": "Force calibration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Force calibration", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "device is not calibrated", + "1": "device is calibrated", + "2": "force device calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Force calibration" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 151, + "propertyName": "Operating mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating mode", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "roller blind", + "2": "Venetian blind", + "3": "gate w/o positioning", + "4": "gate with positioning", + "5": "roller blind with built-in driver", + "6": "roller blind with built-in driver (impulse)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Operating mode" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 152, + "propertyName": "Venetian blind - time of full turn of the slats", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian blind - time of full turn of the slats", + "default": 150, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Venetian blind - time of full turn of the slats" + }, + "value": 150 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 153, + "propertyName": "Set slats back to previous position", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Set slats back to previous position", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Main controller operation", + "1": "Controller, Momentary Switch, Limit Switch", + "2": "Controller, both Switches, Multilevel Stop" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Set slats back to previous position" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 154, + "propertyName": "Delay motor stop", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Delay motor stop after reaching end switch", + "label": "Delay motor stop", + "default": 10, + "min": 0, + "max": 255, + "unit": "1/10 sec", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Delay motor stop", + "info": "Delay motor stop after reaching end switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 155, + "propertyName": "Motor operation detection", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power threshold to be interpreted as reaching a limit switch", + "label": "Motor operation detection", + "default": 10, + "min": 0, + "max": 255, + "unit": "W", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Motor operation detection", + "info": "Power threshold to be interpreted as reaching a limit switch" + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 156, + "propertyName": "Time of up movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of up movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of up movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 157, + "propertyName": "Time of down movement", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Time of down movement", + "default": 6000, + "min": 0, + "max": 65535, + "unit": "1/100 sec", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Time of down movement" + }, + "value": 5000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm #1: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #1 is triggered", + "label": "Alarm #1: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #1: Action", + "info": "Which action to perform when Alarm #1 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm #1: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #1 should be limited to", + "label": "Alarm #1: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Event/State Parameters", + "info": "Which event parameters Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm #1: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #1 should be limited to", + "label": "Alarm #1: Notification Status", + "default": 0, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Status", + "info": "Which notification status Alarm #1 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm #1: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #1", + "label": "Alarm #1: Notification Type", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #1: Notification Type", + "info": "Which notification type should raise Alarm #1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm #2: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #2 is triggered", + "label": "Alarm #2: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #2: Action", + "info": "Which action to perform when Alarm #2 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm #2: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #2 should be limited to", + "label": "Alarm #2: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Event/State Parameters", + "info": "Which event parameters Alarm #2 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm #2: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #2 should be limited to", + "label": "Alarm #2: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Status", + "info": "Which notification status Alarm #2 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm #2: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #2", + "label": "Alarm #2: Notification Type", + "default": 5, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #2: Notification Type", + "info": "Which notification type should raise Alarm #2" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm #3: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #3 is triggered", + "label": "Alarm #3: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #3: Action", + "info": "Which action to perform when Alarm #3 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm #3: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #3 should be limited to", + "label": "Alarm #3: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Event/State Parameters", + "info": "Which event parameters Alarm #3 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm #3: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #3 should be limited to", + "label": "Alarm #3: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Status", + "info": "Which notification status Alarm #3 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm #3: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #3", + "label": "Alarm #3: Notification Type", + "default": 1, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #3: Notification Type", + "info": "Which notification type should raise Alarm #3" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm #4: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #4 is triggered", + "label": "Alarm #4: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #4: Action", + "info": "Which action to perform when Alarm #4 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm #4: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #4 should be limited to", + "label": "Alarm #4: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Event/State Parameters", + "info": "Which event parameters Alarm #4 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm #4: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #4 should be limited to", + "label": "Alarm #4: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Status", + "info": "Which notification status Alarm #4 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm #4: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #4", + "label": "Alarm #4: Notification Type", + "default": 2, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #4: Notification Type", + "info": "Which notification type should raise Alarm #4" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm #5: Action", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which action to perform when Alarm #5 is triggered", + "label": "Alarm #5: Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No action", + "1": "Open blinds", + "2": "Close blinds" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Alarm #5: Action", + "info": "Which action to perform when Alarm #5 is triggered" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm #5: Event/State Parameters", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which event parameters Alarm #5 should be limited to", + "label": "Alarm #5: Event/State Parameters", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Event/State Parameters", + "info": "Which event parameters Alarm #5 should be limited to" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm #5: Notification Status", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification status Alarm #5 should be limited to", + "label": "Alarm #5: Notification Status", + "default": 255, + "min": 0, + "max": 255, + "states": { + "255": "Any" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Status", + "info": "Which notification status Alarm #5 should be limited to" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm #5: Notification Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Which notification type should raise Alarm #5", + "label": "Alarm #5: Notification Type", + "default": 4, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Alarm #5: Notification Type", + "info": "Which notification type should raise Alarm #5" + }, + "value": 4 + }, + { + "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": 271 + }, + { + "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": 771 + }, + { + "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": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Node ID with exclusive control", + "min": 1, + "max": 232, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection timeout", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "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": "6.2" + }, + { + "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": ["5.1", "5.1"] + }, + { + "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": 3 + }, + { + "endpoint": 1, + "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": "unknown" + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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": 99 + }, + { + "endpoint": 1, + "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 + }, + "value": false + }, + { + "endpoint": 1, + "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": 1, + "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": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0.0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "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": "unknown" + }, + { + "endpoint": 2, + "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": 2, + "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": 2, + "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 + }, + "value": false + }, + { + "endpoint": 2, + "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": 2, + "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 + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x010f:0x0303:0x1000:5.1", + "statistics": { + "commandsTX": 8, + "commandsRX": 13, + "commandsDroppedRX": 12, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 155.4, + "rssi": -66, + "lwr": { + "protocolDataRate": 2, + "repeaters": [11], + "rssi": -56, + "repeaterRSSI": [-55] + }, + "nlwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -89, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index e51b3751ac8..fc593de883b 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -47,7 +47,8 @@ GDC_COVER_ENTITY = "cover.aeon_labs_garage_door_controller_gen5" BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" -FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_222_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +FIBARO_FGR_223_SHUTTER_COVER_ENTITY = "cover.fgr_223_test_cover" LOGGER.setLevel(logging.DEBUG) @@ -238,7 +239,7 @@ async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER @@ -249,7 +250,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_OPEN_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -271,7 +272,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_CLOSE_COVER_TILT, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -293,7 +294,7 @@ async def test_fibaro_fgr222_shutter_cover( await hass.services.async_call( DOMAIN, SERVICE_SET_COVER_TILT_POSITION, - {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + {ATTR_ENTITY_ID: FIBARO_FGR_222_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, blocking=True, ) @@ -330,7 +331,101 @@ async def test_fibaro_fgr222_shutter_cover( }, ) fibaro_fgr222_shutter.receive_event(event) - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 + + +async def test_fibaro_fgr223_shutter_cover( + hass: HomeAssistant, client, fibaro_fgr223_shutter, integration +) -> None: + """Test tilt function of the Fibaro Shutter devices.""" + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER + + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + # Test opening tilts + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() + # Test closing tilts + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + # Test setting tilt position + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: FIBARO_FGR_223_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 10 + assert args["valueId"] == { + "endpoint": 2, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 12 + + # Test some tilt + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 10, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 2, + "property": "currentValue", + "newValue": 99, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + fibaro_fgr223_shutter.receive_event(event) + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) assert state assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 100 @@ -694,13 +789,42 @@ async def test_fibaro_fgr222_shutter_cover_no_tilt( client.driver.controller.emit("node added", {"node": node}) await hass.async_block_till_done() - state = hass.states.get(FIBARO_SHUTTER_COVER_ENTITY) + state = hass.states.get(FIBARO_FGR_222_SHUTTER_COVER_ENTITY) assert state assert state.state == STATE_UNKNOWN assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes +async def test_fibaro_fgr223_shutter_cover_no_tilt( + hass: HomeAssistant, client, fibaro_fgr223_shutter_state, integration +) -> None: + """Test absence of tilt function for Fibaro Shutter roller blind. + + Fibaro Shutter devices can have operating mode set to roller blind (1). + """ + node_state = replace_value_of_zwave_value( + fibaro_fgr223_shutter_state, + [ + ZwaveValueMatcher( + property_=151, + command_class=CommandClass.CONFIGURATION, + endpoint=0, + ), + ], + 1, + ) + node = Node(client, node_state) + client.driver.controller.emit("node added", {"node": node}) + await hass.async_block_till_done() + + state = hass.states.get(FIBARO_FGR_223_SHUTTER_COVER_ENTITY) + assert state + assert state.state == STATE_OPEN + assert ATTR_CURRENT_POSITION in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + async def test_iblinds_v3_cover( hass: HomeAssistant, client, iblinds_v3, integration ) -> None: From 217a895b5a0ffe67ae17f184ca577b4ed7cff955 Mon Sep 17 00:00:00 2001 From: Tereza Tomcova Date: Thu, 28 Sep 2023 14:15:22 +0300 Subject: [PATCH 022/968] Bump PySwitchbot to 0.40.0 to support Curtain 3 (#100619) --- 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 49a6af2b179..e685d1de806 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.39.1"] + "requirements": ["PySwitchbot==0.40.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0fc8675bdbd..552e9725bc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36529f398e7..daa1571757f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.39.1 +PySwitchbot==0.40.0 # homeassistant.components.syncthru PySyncThru==0.7.10 From f0ca27fd083b69961c256cd368dd687513b93b1b Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Thu, 28 Sep 2023 16:28:38 +0200 Subject: [PATCH 023/968] Add reload to rest_command integration (#100511) * Add YAML reload to rest_command integration * Add rest_command reload tests * Fix test coverage * Remove unnecessary call to keys Co-authored-by: J. Nick Koston * Perform cleanup on reload with empty config * Fix mypy * Fix ruff * Update homeassistant/components/rest_command/__init__.py * Update __init__.py --------- Co-authored-by: J. Nick Koston Co-authored-by: Erik Montnemery --- .../components/rest_command/__init__.py | 23 ++++++++++++++ tests/components/rest_command/test_init.py | 31 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py index 1007ee1d2de..dcf790748ec 100644 --- a/homeassistant/components/rest_command/__init__.py +++ b/homeassistant/components/rest_command/__init__.py @@ -16,10 +16,12 @@ from homeassistant.const import ( CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, + SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.typing import ConfigType DOMAIN = "rest_command" @@ -58,6 +60,23 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the REST command component.""" + async def reload_service_handler(service: ServiceCall) -> None: + """Remove all rest_commands and load new ones from config.""" + conf = await async_integration_yaml_config(hass, DOMAIN) + + # conf will be None if the configuration can't be parsed + if conf is None: + return + + existing = hass.services.async_services().get(DOMAIN, {}) + for existing_service in existing: + if existing_service == SERVICE_RELOAD: + continue + hass.services.async_remove(DOMAIN, existing_service) + + for name, command_config in conf[DOMAIN].items(): + async_register_rest_command(name, command_config) + @callback def async_register_rest_command(name, command_config): """Create service for rest command.""" @@ -156,4 +175,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for name, command_config in config[DOMAIN].items(): async_register_rest_command(name, command_config) + hass.services.async_register( + DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) + ) + return True diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py index 7cf94dcf846..c43fe84ea8f 100644 --- a/tests/components/rest_command/test_init.py +++ b/tests/components/rest_command/test_init.py @@ -1,11 +1,16 @@ """The tests for the rest command platform.""" import asyncio from http import HTTPStatus +from unittest.mock import patch import aiohttp import homeassistant.components.rest_command as rc -from homeassistant.const import CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN +from homeassistant.const import ( + CONTENT_TYPE_JSON, + CONTENT_TYPE_TEXT_PLAIN, + SERVICE_RELOAD, +) from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -43,6 +48,30 @@ class TestRestCommandSetup: assert self.hass.services.has_service(rc.DOMAIN, "test_get") + def test_reload(self): + """Verify we can reload rest_command integration.""" + + with assert_setup_component(1): + setup_component(self.hass, rc.DOMAIN, self.config) + + assert self.hass.services.has_service(rc.DOMAIN, "test_get") + assert not self.hass.services.has_service(rc.DOMAIN, "new_test") + + new_config = { + rc.DOMAIN: { + "new_test": {"url": "https://example.org", "method": "get"}, + } + } + with patch( + "homeassistant.config.load_yaml_config_file", + autospec=True, + return_value=new_config, + ): + self.hass.services.call(rc.DOMAIN, SERVICE_RELOAD, blocking=True) + + assert self.hass.services.has_service(rc.DOMAIN, "new_test") + assert not self.hass.services.has_service(rc.DOMAIN, "get_test") + class TestRestCommandComponent: """Test the rest command component.""" From d8520088e72fe260ea215db7f10f3b62e808138e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 28 Sep 2023 16:52:16 +0200 Subject: [PATCH 024/968] Update aioairzone-cloud to v0.2.3 (#101052) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- .../components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 31 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 63d9d3fffaa..1a158fcd1fe 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.2"] + "requirements": ["aioairzone-cloud==0.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 552e9725bc9..25c8123a35e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.2 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daa1571757f..0612e93d84a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.2 +aioairzone-cloud==0.2.3 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index fb33323378a..44bd0e45e2a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -113,6 +113,7 @@ 'active': True, 'available': True, 'humidity': 27, + 'id': 'group1', 'installation': 'installation1', 'mode': 2, 'modes': list([ @@ -144,6 +145,7 @@ 'aidoo1', ]), 'available': True, + 'id': 'grp2', 'installation': 'installation1', 'mode': 3, 'modes': list([ @@ -165,12 +167,41 @@ }), 'installations': dict({ 'installation1': dict({ + 'action': 1, + 'active': True, + 'aidoos': list([ + 'aidoo1', + ]), + 'available': True, + 'humidity': 27, 'id': 'installation1', + 'mode': 2, + 'modes': list([ + 1, + 2, + 3, + 4, + 5, + ]), 'name': 'House', + 'num-devices': 3, + 'power': True, + 'systems': list([ + 'system1', + ]), + 'temperature': 22.0, + 'temperature-setpoint': 23.3, + 'temperature-setpoint-max': 30.0, + 'temperature-setpoint-min': 15.0, + 'temperature-step': 0.5, 'web-servers': list([ 'webserver1', '11:22:33:44:55:67', ]), + 'zones': list([ + 'zone1', + 'zone2', + ]), }), }), 'systems': dict({ From dc78d15abc94bb75769d6090da646343af31760c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Sep 2023 17:45:10 +0200 Subject: [PATCH 025/968] Add LED control support to Home Assistant Green (#100922) * Add LED control support to Home Assistant Green * Add strings.json * Sort alphabetically * Reorder LED schema * Improve test coverage * Apply suggestions from code review Co-authored-by: Stefan Agner * Sort + fix test * Remove reboot menu --------- Co-authored-by: Stefan Agner --- homeassistant/components/hassio/__init__.py | 2 + homeassistant/components/hassio/handler.py | 21 +++ .../homeassistant_green/config_flow.py | 80 ++++++++- .../homeassistant_green/strings.json | 28 +++ tests/components/hassio/test_handler.py | 42 +++++ .../homeassistant_green/test_config_flow.py | 164 ++++++++++++++++++ 6 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/homeassistant_green/strings.json diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 3303059d824..75b2535bd44 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -88,11 +88,13 @@ from .handler import ( # noqa: F401 async_get_addon_discovery_info, async_get_addon_info, async_get_addon_store_info, + async_get_green_settings, async_get_yellow_settings, async_install_addon, async_reboot_host, async_restart_addon, async_set_addon_options, + async_set_green_settings, async_set_yellow_settings, async_start_addon, async_stop_addon, diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 020a4365ec6..fe9e1ba1d2e 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -263,6 +263,27 @@ async def async_apply_suggestion(hass: HomeAssistant, suggestion_uuid: str) -> b return await hassio.send_command(command, timeout=None) +@api_data +async def async_get_green_settings(hass: HomeAssistant) -> dict[str, bool]: + """Return settings specific to Home Assistant Green.""" + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command("/os/boards/green", method="get") + + +@api_data +async def async_set_green_settings( + hass: HomeAssistant, settings: dict[str, bool] +) -> dict: + """Set settings specific to Home Assistant Green. + + Returns an empty dict. + """ + hassio: HassIO = hass.data[DOMAIN] + return await hassio.send_command( + "/os/boards/green", method="post", payload=settings + ) + + @api_data async def async_get_yellow_settings(hass: HomeAssistant) -> dict[str, bool]: """Return settings specific to Home Assistant Yellow.""" diff --git a/homeassistant/components/homeassistant_green/config_flow.py b/homeassistant/components/homeassistant_green/config_flow.py index 17ba9aacbc5..c3491de430e 100644 --- a/homeassistant/components/homeassistant_green/config_flow.py +++ b/homeassistant/components/homeassistant_green/config_flow.py @@ -1,22 +1,100 @@ """Config flow for the Home Assistant Green integration.""" from __future__ import annotations +import asyncio +import logging from typing import Any -from homeassistant.config_entries import ConfigFlow +import aiohttp +import voluptuous as vol + +from homeassistant.components.hassio import ( + HassioAPIError, + async_get_green_settings, + async_set_green_settings, + is_hassio, +) +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + +STEP_HW_SETTINGS_SCHEMA = vol.Schema( + { + # Sorted to match front panel left to right + vol.Required("power_led"): selector.BooleanSelector(), + vol.Required("activity_led"): selector.BooleanSelector(), + vol.Required("system_health_led"): selector.BooleanSelector(), + } +) + class HomeAssistantGreenConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Home Assistant Green.""" VERSION = 1 + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> HomeAssistantGreenOptionsFlow: + """Return the options flow.""" + return HomeAssistantGreenOptionsFlow() + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: """Handle the initial step.""" if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") return self.async_create_entry(title="Home Assistant Green", data={}) + + +class HomeAssistantGreenOptionsFlow(OptionsFlow): + """Handle an option flow for Home Assistant Green.""" + + _hw_settings: dict[str, bool] | None = None + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if not is_hassio(self.hass): + return self.async_abort(reason="not_hassio") + + return await self.async_step_hardware_settings() + + async def async_step_hardware_settings( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle hardware settings.""" + + if user_input is not None: + if self._hw_settings == user_input: + return self.async_create_entry(data={}) + try: + async with asyncio.timeout(10): + await async_set_green_settings(self.hass, user_input) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to write hardware settings", exc_info=err) + return self.async_abort(reason="write_hw_settings_error") + return self.async_create_entry(data={}) + + try: + async with asyncio.timeout(10): + self._hw_settings: dict[str, bool] = await async_get_green_settings( + self.hass + ) + except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: + _LOGGER.warning("Failed to read hardware settings", exc_info=err) + return self.async_abort(reason="read_hw_settings_error") + + schema = self.add_suggested_values_to_schema( + STEP_HW_SETTINGS_SCHEMA, self._hw_settings + ) + + return self.async_show_form(step_id="hardware_settings", data_schema=schema) diff --git a/homeassistant/components/homeassistant_green/strings.json b/homeassistant/components/homeassistant_green/strings.json new file mode 100644 index 00000000000..9066ca64e5c --- /dev/null +++ b/homeassistant/components/homeassistant_green/strings.json @@ -0,0 +1,28 @@ +{ + "options": { + "step": { + "hardware_settings": { + "title": "Configure hardware settings", + "data": { + "activity_led": "Green: activity LED", + "power_led": "White: power LED", + "system_health_led": "Yellow: system health LED" + } + }, + "reboot_menu": { + "title": "Reboot required", + "description": "The settings have changed, but the new settings will not take effect until the system is rebooted", + "menu_options": { + "reboot_later": "Reboot manually later", + "reboot_now": "Reboot now" + } + } + }, + "abort": { + "not_hassio": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::not_hassio%]", + "read_hw_settings_error": "Failed to read hardware settings", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "write_hw_settings_error": "Failed to write hardware settings" + } + } +} diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index d92a5335809..06c726360d9 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -364,6 +364,48 @@ async def test_api_headers( assert received_request.headers[hdrs.CONTENT_TYPE] == "application/octet-stream" +async def test_api_get_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.get( + "http://127.0.0.1/os/boards/green", + json={ + "result": "ok", + "data": { + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + }, + ) + + assert await handler.async_get_green_settings(hass) == { + "activity_led": True, + "power_led": True, + "system_health_led": True, + } + assert aioclient_mock.call_count == 1 + + +async def test_api_set_green_settings( + hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker +) -> None: + """Test setup with API ping.""" + aioclient_mock.post( + "http://127.0.0.1/os/boards/green", + json={"result": "ok", "data": {}}, + ) + + assert ( + await handler.async_set_green_settings( + hass, {"activity_led": True, "power_led": True, "system_health_led": True} + ) + == {} + ) + assert aioclient_mock.call_count == 1 + + async def test_api_get_yellow_settings( hass: HomeAssistant, hassio_stubs, aioclient_mock: AiohttpClientMocker ) -> None: diff --git a/tests/components/homeassistant_green/test_config_flow.py b/tests/components/homeassistant_green/test_config_flow.py index 2eb7389af55..84af22509f9 100644 --- a/tests/components/homeassistant_green/test_config_flow.py +++ b/tests/components/homeassistant_green/test_config_flow.py @@ -1,6 +1,8 @@ """Test the Home Assistant Green config flow.""" from unittest.mock import patch +import pytest + from homeassistant.components.homeassistant_green.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -8,6 +10,29 @@ from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(name="get_green_settings") +def mock_get_green_settings(): + """Mock getting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + return_value={ + "activity_led": True, + "power_led": True, + "system_health_led": True, + }, + ) as get_green_settings: + yield get_green_settings + + +@pytest.fixture(name="set_green_settings") +def mock_set_green_settings(): + """Mock setting green settings.""" + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + ) as set_green_settings: + yield set_green_settings + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -56,3 +81,142 @@ async def test_config_flow_single_entry(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" mock_setup_entry.assert_not_called() + + +async def test_option_flow_non_hassio( + hass: HomeAssistant, +) -> None: + """Test installing the multi pan addon on a Core installation, without hassio.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.is_hassio", + return_value=False, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "not_hassio" + + +async def test_option_flow_led_settings( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_called_once_with( + hass, {"activity_led": False, "power_led": False, "system_health_led": False} + ) + + +async def test_option_flow_led_settings_unchanged( + hass: HomeAssistant, + get_green_settings, + set_green_settings, +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": True, "power_led": True, "system_health_led": True}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + set_green_settings.assert_not_called() + + +async def test_option_flow_led_settings_fail_1(hass: HomeAssistant) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_get_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "read_hw_settings_error" + + +async def test_option_flow_led_settings_fail_2( + hass: HomeAssistant, get_green_settings +) -> None: + """Test updating LED settings.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Green", + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "hardware_settings" + + with patch( + "homeassistant.components.homeassistant_green.config_flow.async_set_green_settings", + side_effect=TimeoutError, + ): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + {"activity_led": False, "power_led": False, "system_health_led": False}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "write_hw_settings_error" From 3b381f10d30ede897a75531333db9dcbbcdbe5a2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 18:52:23 +0200 Subject: [PATCH 026/968] Bump aiowaqi to 1.1.0 (#99751) * Bump aiowaqi to 1.1.0 * Fix hassfest * Fix tests --- homeassistant/components/waqi/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/waqi/test_config_flow.py | 16 ++++++++-------- tests/components/waqi/test_sensor.py | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index bf31fb570a8..76e25225b7d 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -5,6 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", - "loggers": ["waqiasync"], - "requirements": ["aiowaqi==0.2.1"] + "loggers": ["aiowaqi"], + "requirements": ["aiowaqi==1.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 25c8123a35e..8665eddbd44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0612e93d84a..59e028499f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==0.2.1 +aiowaqi==1.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py index be738a119e5..7a95e000d82 100644 --- a/tests/components/waqi/test_config_flow.py +++ b/tests/components/waqi/test_config_flow.py @@ -57,7 +57,7 @@ async def test_full_map_flow( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -74,12 +74,12 @@ async def test_full_map_flow( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -133,7 +133,7 @@ async def test_flow_errors( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -150,7 +150,7 @@ async def test_flow_errors( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -220,7 +220,7 @@ async def test_error_in_second_step( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_ip", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -251,12 +251,12 @@ async def test_error_in_second_step( "aiowaqi.WAQIClient.authenticate", ), patch( "aiowaqi.WAQIClient.get_by_coordinates", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 18f77028a29..ef434bcc544 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -36,7 +36,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" search_result_json = json.loads(load_fixture("waqi/search_result.json")) search_results = [ - WAQISearchResult.parse_obj(search_result) + WAQISearchResult.from_dict(search_result) for search_result in search_result_json ] with patch( @@ -44,7 +44,7 @@ async def test_legacy_migration(hass: HomeAssistant) -> None: return_value=search_results, ), patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -64,7 +64,7 @@ async def test_legacy_migration_already_imported( mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): @@ -98,7 +98,7 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - mock_config_entry.add_to_hass(hass) with patch( "aiowaqi.WAQIClient.get_by_station_number", - return_value=WAQIAirQuality.parse_obj( + return_value=WAQIAirQuality.from_dict( json.loads(load_fixture("waqi/air_quality_sensor.json")) ), ): From f255a0e546fb36b70888d54435cc8c33b1fc11a9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 19:06:45 +0200 Subject: [PATCH 027/968] Pin pydantic to 1.10.12 (#101044) --- homeassistant/package_constraints.txt | 5 +++-- script/gen_requirements_all.py | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bf287f564cc..678195986e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -126,8 +126,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e0e00ebc958..a8bc99d68fa 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -128,8 +128,9 @@ multidict>=6.0.2 # Version 2.0 added typing, prevent accidental fallbacks backoff>=2.0 -# Require to avoid issues with decorators (#93904). v2 has breaking changes. -pydantic>=1.10.8,<2.0 +# Required to avoid breaking (#101042). +# v2 has breaking changes (#99218). +pydantic==1.10.12 # Breaks asyncio # https://github.com/pubnub/python/issues/130 From f39b2716b036a4519cd7ce93df4a3144e60f83ac Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 28 Sep 2023 12:07:00 -0500 Subject: [PATCH 028/968] Remove fma instructions from webrtc-noise-gain (#101060) --- homeassistant/components/assist_pipeline/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/assist_pipeline/manifest.json b/homeassistant/components/assist_pipeline/manifest.json index db6c517a81a..31b3b0d4e32 100644 --- a/homeassistant/components/assist_pipeline/manifest.json +++ b/homeassistant/components/assist_pipeline/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/assist_pipeline", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["webrtc-noise-gain==1.2.2"] + "requirements": ["webrtc-noise-gain==1.2.3"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 678195986e4..83e7f7d45b9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -50,7 +50,7 @@ typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 yarl==1.9.2 zeroconf==0.115.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8665eddbd44..60b01a83344 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2700,7 +2700,7 @@ waterfurnace==1.1.0 webexteamssdk==1.1.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59e028499f3..bb1a143b9db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2006,7 +2006,7 @@ wallbox==0.4.12 watchdog==2.3.1 # homeassistant.components.assist_pipeline -webrtc-noise-gain==1.2.2 +webrtc-noise-gain==1.2.3 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.18.4 From 0a540e1cdb44544f0ba400879d82155f80e8ec20 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 28 Sep 2023 10:07:22 -0700 Subject: [PATCH 029/968] Bump apple_weatherkit to 1.0.4 (#101057) --- homeassistant/components/weatherkit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json index 34a5d45ca1f..d28a6ff3315 100644 --- a/homeassistant/components/weatherkit/manifest.json +++ b/homeassistant/components/weatherkit/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/weatherkit", "iot_class": "cloud_polling", - "requirements": ["apple_weatherkit==1.0.3"] + "requirements": ["apple_weatherkit==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 60b01a83344..f42f8ec889d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,7 +423,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb1a143b9db..b5dc755c185 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,7 +389,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.3 +apple_weatherkit==1.0.4 # homeassistant.components.apprise apprise==1.5.0 From f733c439005f8d81e485512fb399778b01d965a6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 28 Sep 2023 19:08:26 +0200 Subject: [PATCH 030/968] Don't show withings repair if it's not in YAML (#101054) --- homeassistant/components/withings/__init__.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 7b6a56995c8..44d32b0603c 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -76,29 +76,30 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.4.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Withings", - }, - ) - if CONF_CLIENT_ID in config: - await async_import_client_credential( + if conf := config.get(DOMAIN): + async_create_issue( hass, - DOMAIN, - ClientCredential( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ), + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Withings", + }, ) + if CONF_CLIENT_ID in conf: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + conf[CONF_CLIENT_ID], + conf[CONF_CLIENT_SECRET], + ), + ) return True From 77c519220df39b1e901296cfac175b8e70a6ba91 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 Sep 2023 20:35:49 +0200 Subject: [PATCH 031/968] Use dataclass for mqtt Subscription (#101064) --- homeassistant/components/mqtt/client.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 733645c4788..02f3edd155a 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass from functools import lru_cache from itertools import chain, groupby import logging @@ -12,7 +13,6 @@ import time from typing import TYPE_CHECKING, Any import uuid -import attr import certifi from homeassistant.config_entries import ConfigEntry @@ -212,15 +212,15 @@ def subscribe( return remove -@attr.s(slots=True, frozen=True) +@dataclass(frozen=True) class Subscription: """Class to hold data about an active subscription.""" - topic: str = attr.ib() - matcher: Any = attr.ib() - job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] = attr.ib() - qos: int = attr.ib(default=0) - encoding: str | None = attr.ib(default="utf-8") + topic: str + matcher: Any + job: HassJob[[ReceiveMessage], Coroutine[Any, Any, None] | None] + qos: int = 0 + encoding: str | None = "utf-8" class MqttClientSetup: From 0ded0ef4ee2f38baff4c70546b79e66c7baa6031 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 28 Sep 2023 20:36:30 +0200 Subject: [PATCH 032/968] Use dataclass instead of attr slots for mqtt PublishMessage and ReceiveMessage (#101062) Use dataclass instead of attr slots --- homeassistant/components/mqtt/models.py | 30 ++++++++++++------------- tests/components/mqtt/test_init.py | 4 +++- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 8c599469ff2..23faa726e09 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -11,8 +11,6 @@ from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict -import attr - from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import template @@ -44,26 +42,26 @@ ATTR_THIS = "this" PublishPayloadType = str | bytes | int | float | None -@attr.s(slots=True, frozen=True) +@dataclass class PublishMessage: - """MQTT Message.""" + """MQTT Message for publishing.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() + topic: str + payload: PublishPayloadType + qos: int + retain: bool -@attr.s(slots=True, frozen=True) +@dataclass class ReceiveMessage: - """MQTT Message.""" + """MQTT Message received.""" - topic: str = attr.ib() - payload: ReceivePayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - subscribed_topic: str = attr.ib(default=None) - timestamp: dt.datetime = attr.ib(default=None) + topic: str + payload: ReceivePayloadType + qos: int + retain: bool + subscribed_topic: str + timestamp: dt.datetime AsyncMessageCallbackType = Callable[[ReceiveMessage], Coroutine[Any, Any, None]] diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 48d949ae927..ccd175fe296 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2085,7 +2085,9 @@ async def test_handle_message_callback( callbacks.append(args) mock_mqtt = await mqtt_mock_entry() - msg = ReceiveMessage("some-topic", b"test-payload", 1, False) + msg = ReceiveMessage( + "some-topic", b"test-payload", 1, False, "some-topic", datetime.now() + ) mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await mqtt.async_subscribe(hass, "some-topic", _callback) mqtt_client_mock.on_message(mock_mqtt, None, msg) From dbd0c06518ae85419281fba4f1d569107fe2c445 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 28 Sep 2023 20:48:07 +0200 Subject: [PATCH 033/968] Update frontend to 20230928.0 (#101067) --- 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 aa417b6e714..9f01fadb710 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==20230926.0"] + "requirements": ["home-assistant-frontend==20230928.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 83e7f7d45b9..13cc25cdf80 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 home-assistant-intents==2023.9.22 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f42f8ec889d..47a240325c7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5dc755c185..4dd71713715 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230926.0 +home-assistant-frontend==20230928.0 # homeassistant.components.conversation home-assistant-intents==2023.9.22 From d73cc1eecd9eff128606b6cb57ac45432667dc59 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 28 Sep 2023 15:30:43 -0500 Subject: [PATCH 034/968] Use wake word description if available (#101079) --- homeassistant/components/wyoming/wake_word.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index c9010425c52..d4cbd9b9263 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -46,7 +46,8 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): wake_service = service.info.wake[0] self._supported_wake_words = [ - wake_word.WakeWord(id=ww.name, name=ww.name) for ww in wake_service.models + wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) + for ww in wake_service.models ] self._attr_name = wake_service.name self._attr_unique_id = f"{config_entry.entry_id}-wake_word" From 136fbaa2a8e56f818fbf3551cc694b74d38362ca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 28 Sep 2023 22:38:21 +0200 Subject: [PATCH 035/968] Remove dead code from broadlink light (#101063) --- homeassistant/components/broadlink/light.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 796698c6a4c..57797ca592a 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,7 +6,6 @@ from broadlink.exceptions import BroadlinkException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ColorMode, @@ -113,16 +112,6 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): state["colortemp"] = (color_temp - 153) * 100 + 2700 state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - elif ATTR_COLOR_MODE in kwargs: - color_mode = kwargs[ATTR_COLOR_MODE] - if color_mode == ColorMode.HS: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif color_mode == ColorMode.COLOR_TEMP: - state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE - else: - # Scenes are not yet supported. - state["bulb_colormode"] = BROADLINK_COLOR_MODE_SCENES - await self._async_set_state(state) async def async_turn_off(self, **kwargs: Any) -> None: From 42b2a462c1d1194c9222bcc2578d1f5eef738f87 Mon Sep 17 00:00:00 2001 From: tronikos Date: Thu, 28 Sep 2023 13:38:33 -0700 Subject: [PATCH 036/968] Bump opower to 0.0.35 (#101072) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 002495b9517..71fd841d0fc 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.34"] + "requirements": ["opower==0.0.35"] } diff --git a/requirements_all.txt b/requirements_all.txt index 47a240325c7..1a9419fbb31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1383,7 +1383,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dd71713715..e8746759811 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1061,7 +1061,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.34 +opower==0.0.35 # homeassistant.components.oralb oralb-ble==0.17.6 From 063bac1665cdd4b5e6e83d34d8982aebdf5a9b60 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 28 Sep 2023 14:24:07 -0700 Subject: [PATCH 037/968] Add native precipitation unit for weatherkit (#101073) --- homeassistant/components/weatherkit/weather.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index 07745680b01..ce997fa500f 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -134,6 +134,7 @@ class WeatherKitWeather( _attr_native_pressure_unit = UnitOfPressure.MBAR _attr_native_visibility_unit = UnitOfLength.KILOMETERS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = UnitOfLength.MILLIMETERS def __init__( self, From d1347d23de52eeeadfeb03b116d02c117cf5b954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 28 Sep 2023 23:29:53 +0200 Subject: [PATCH 038/968] Update aioairzone-cloud to v0.2.4 (#101069) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/airzone_cloud/snapshots/test_diagnostics.ambr | 5 +++++ 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 1a158fcd1fe..418b6538a42 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.3"] + "requirements": ["aioairzone-cloud==0.2.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a9419fbb31..29de19bf72f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -186,7 +186,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.3 +aioairzone-cloud==0.2.4 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e8746759811..0b3b2731db0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -167,7 +167,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.3 +aioairzone-cloud==0.2.4 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr index 44bd0e45e2a..1d1d060e80a 100644 --- a/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr +++ b/tests/components/airzone_cloud/snapshots/test_diagnostics.ambr @@ -173,6 +173,10 @@ 'aidoo1', ]), 'available': True, + 'groups': list([ + 'group1', + 'grp2', + ]), 'humidity': 27, 'id': 'installation1', 'mode': 2, @@ -185,6 +189,7 @@ ]), 'name': 'House', 'num-devices': 3, + 'num-groups': 2, 'power': True, 'systems': list([ 'system1', From f5d8d41ad51a3632be13431ec87d4d7b38d6024f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 29 Sep 2023 03:56:17 +0200 Subject: [PATCH 039/968] Fix ZHA exception when writing `cie_addr` during configuration (#101087) Fix ZHA exception when writing `cie_addr` --- .../components/zha/core/cluster_handlers/security.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/security.py b/homeassistant/components/zha/core/cluster_handlers/security.py index f31830f0bd8..9c74a14daa8 100644 --- a/homeassistant/components/zha/core/cluster_handlers/security.py +++ b/homeassistant/components/zha/core/cluster_handlers/security.py @@ -369,12 +369,11 @@ class IASZoneClusterHandler(ClusterHandler): ieee = self.cluster.endpoint.device.application.state.node_info.ieee try: - res = await self.write_attributes_safe({"cie_addr": ieee}) + await self.write_attributes_safe({"cie_addr": ieee}) self.debug( - "wrote cie_addr: %s to '%s' cluster: %s", + "wrote cie_addr: %s to '%s' cluster", str(ieee), self._cluster.ep_attribute, - res[0], ) except HomeAssistantError as ex: self.debug( From 845d28255eb3eb518a56b1a94fa3c7983755c568 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Fri, 29 Sep 2023 07:05:26 +0200 Subject: [PATCH 040/968] Update xknxproject to 3.3.0 (#101081) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index a915d886138..b5c98c7203a 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.2.0", + "xknxproject==3.3.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 29de19bf72f..c15dc6822d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0b3b2731db0..d52d9e37872 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2039,7 +2039,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.2.0 +xknxproject==3.3.0 # homeassistant.components.bluesound # homeassistant.components.fritz From fb61e34833d05ce5825ba07f91cc24666a651b26 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 08:58:05 +0200 Subject: [PATCH 041/968] Update Home Assistant base image to 2023.09.0 (#101092) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index cc13a4e595f..f9e19f89e23 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.08.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.08.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.08.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.08.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.08.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 2a6a2fa8422f0066b8e3791040a96da38bc94c77 Mon Sep 17 00:00:00 2001 From: Mike <7278201+mike391@users.noreply.github.com> Date: Fri, 29 Sep 2023 03:01:04 -0400 Subject: [PATCH 042/968] Update pyvesync to 2.1.10 (#100522) * Update manifest.json to use pyvesync 2.1.10 * Update Requirements for pyvesync * Update test_diagnostics.ambr --------- Co-authored-by: Thibault Cohen <47721+titilambert@users.noreply.github.com> --- homeassistant/components/vesync/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vesync/snapshots/test_diagnostics.ambr | 4 ++++ 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json index dcf8e7d2860..fb892acfd4f 100644 --- a/homeassistant/components/vesync/manifest.json +++ b/homeassistant/components/vesync/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vesync", "iot_class": "cloud_polling", "loggers": ["pyvesync"], - "requirements": ["pyvesync==2.1.1"] + "requirements": ["pyvesync==2.1.10"] } diff --git a/requirements_all.txt b/requirements_all.txt index c15dc6822d5..32954cf7001 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2234,7 +2234,7 @@ pyvera==0.3.13 pyversasense==0.0.6 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d52d9e37872..4ae06b222f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1663,7 +1663,7 @@ pyuptimerobot==22.2.0 pyvera==0.3.13 # homeassistant.components.vesync -pyvesync==2.1.1 +pyvesync==2.1.10 # homeassistant.components.vizio pyvizio==0.1.61 diff --git a/tests/components/vesync/snapshots/test_diagnostics.ambr b/tests/components/vesync/snapshots/test_diagnostics.ambr index 10dfdd2ba14..b2ae7b53cf5 100644 --- a/tests/components/vesync/snapshots/test_diagnostics.ambr +++ b/tests/components/vesync/snapshots/test_diagnostics.ambr @@ -100,6 +100,7 @@ }), 'device_image': 'https://image.vesync.com/defaultImages/LV_600S_Series/icon_lv600s_humidifier_160.png', 'device_name': 'Humidifier', + 'device_region': 'US', 'device_status': 'off', 'device_type': 'LUH-A602S-WUS', 'enabled': False, @@ -128,6 +129,7 @@ ]), 'mode': None, 'night_light': True, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', @@ -174,6 +176,7 @@ }), 'device_image': '', 'device_name': 'Fan', + 'device_region': 'US', 'device_status': 'unknown', 'device_type': 'LV-PUR131S', 'extension': None, @@ -264,6 +267,7 @@ 'mac_id': '**REDACTED**', 'manager': '**REDACTED**', 'mode': None, + 'pid': None, 'speed': None, 'sub_device_no': None, 'type': 'wifi-air', From 4f905423940fa47a338c8ad1d37b62a8ab67aec7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 09:30:00 +0200 Subject: [PATCH 043/968] Use pep 503 compatible wheels index for builds (#101096) --- Dockerfile | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index e229f27cb33..f2a365b2b8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,9 +15,8 @@ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -39,9 +38,8 @@ RUN \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -49,9 +47,8 @@ COPY . homeassistant/ RUN \ pip3 install \ --no-cache-dir \ - --no-index \ --only-binary=:all: \ - --find-links "${WHEELS_LINKS}" \ + --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant From 809abc144520ef6cee87ff00f6fae326a5764c72 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 29 Sep 2023 11:49:19 +0200 Subject: [PATCH 044/968] Fix circular dependency on homeassistant (#101099) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 13cc25cdf80..61b6de913d5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -173,3 +173,7 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# Circular dependency on homeassistant itself +# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 +alexapy<1.27.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a8bc99d68fa..4291d2c6e2f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -175,6 +175,10 @@ pysnmp==1000000000.0.0 # The get-mac package has been replaced with getmac. Installing get-mac alongside getmac # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 + +# Circular dependency on homeassistant itself +# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 +alexapy<1.27.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From e924622153958c88f62833fac7cc8615e394bd15 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 12:58:29 +0200 Subject: [PATCH 045/968] Ignore binary distribution wheels for charset-normalizer (#101104) --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6c3022b194b..85912623f61 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From 97e5504ddded50592f5c929a9636393a0b1f804a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 13:18:33 +0200 Subject: [PATCH 046/968] Correct binary ignore for charset-normalizer to charset_normalizer (#101106) --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 85912623f61..25245795c56 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From b8a7ad916a38a51f6024abb363ce81920b931891 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 14:43:03 +0200 Subject: [PATCH 047/968] Pin charset-normalizer in our package constraints (#101107) --- .github/workflows/wheels.yml | 6 +++--- homeassistant/package_constraints.txt | 5 +++++ script/gen_requirements_all.py | 5 +++++ 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 25245795c56..85912623f61 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -186,7 +186,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -200,7 +200,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -214,7 +214,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset_normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 61b6de913d5..4f5868e2fff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -177,3 +177,8 @@ get-mac==1000000000.0.0 # Circular dependency on homeassistant itself # https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 alexapy<1.27.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 4291d2c6e2f..e87e8b16bcb 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -179,6 +179,11 @@ get-mac==1000000000.0.0 # Circular dependency on homeassistant itself # https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 alexapy<1.27.0 + +# We want to skip the binary wheels for the 'charset-normalizer' packages. +# They are build with mypyc, but causes issues with our wheel builder. +# In order to do so, we need to constrain the version. +charset-normalizer==3.2.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 50827405d0f43a5613cd59c8535121f4f119ea63 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 29 Sep 2023 16:30:33 +0200 Subject: [PATCH 048/968] Fix patch of PLATFORMS constant in netatmo (#101038) --- tests/components/netatmo/common.py | 8 ++++++-- tests/components/netatmo/test_camera.py | 4 ++-- tests/components/netatmo/test_init.py | 4 ++-- tests/components/netatmo/test_light.py | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/components/netatmo/common.py b/tests/components/netatmo/common.py index a3f7dfcb9d2..0776b80a3cd 100644 --- a/tests/components/netatmo/common.py +++ b/tests/components/netatmo/common.py @@ -92,7 +92,11 @@ async def simulate_webhook(hass, webhook_id, response): @contextmanager def selected_platforms(platforms): """Restrict loaded platforms to list given.""" - with patch("homeassistant.components.netatmo.PLATFORMS", platforms), patch( + with patch( + "homeassistant.components.netatmo.data_handler.PLATFORMS", platforms + ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", - ), patch("homeassistant.components.netatmo.webhook_generate_url"): + ), patch( + "homeassistant.components.netatmo.webhook_generate_url" + ): yield diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 8bfe7176f5d..e9a66cfefc8 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -478,7 +478,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["camera"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["camera"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -491,7 +491,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert fake_post_hits == 11 + assert fake_post_hits == 8 async def test_camera_image_raises_exception( diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index c6146dca339..e04295ae668 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -201,7 +201,7 @@ async def test_setup_with_cloud(hass: HomeAssistant, config_entry) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -267,7 +267,7 @@ async def test_setup_with_cloudhook(hass: HomeAssistant) -> None: ) as fake_delete_cloudhook, patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", [] + "homeassistant.components.netatmo.data_handler.PLATFORMS", [] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( diff --git a/tests/components/netatmo/test_light.py b/tests/components/netatmo/test_light.py index 5a875097636..83218b6d6d1 100644 --- a/tests/components/netatmo/test_light.py +++ b/tests/components/netatmo/test_light.py @@ -99,7 +99,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> with patch( "homeassistant.components.netatmo.api.AsyncConfigEntryNetatmoAuth" ) as mock_auth, patch( - "homeassistant.components.netatmo.PLATFORMS", ["light"] + "homeassistant.components.netatmo.data_handler.PLATFORMS", ["light"] ), patch( "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", ), patch( @@ -120,7 +120,7 @@ async def test_setup_component_no_devices(hass: HomeAssistant, config_entry) -> ) await hass.async_block_till_done() - assert fake_post_hits == 4 + assert fake_post_hits == 3 assert hass.config_entries.async_entries(DOMAIN) assert len(hass.states.async_all()) == 0 From ce083eade926ffd7d70f5147efc4cc70c253a510 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:07:33 +0200 Subject: [PATCH 049/968] Add device class pH to aseko pool live (#101120) --- homeassistant/components/aseko_pool_live/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aseko_pool_live/sensor.py b/homeassistant/components/aseko_pool_live/sensor.py index d7e5e330705..14eedd279b8 100644 --- a/homeassistant/components/aseko_pool_live/sensor.py +++ b/homeassistant/components/aseko_pool_live/sensor.py @@ -60,7 +60,6 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_icon = { "clf": "mdi:flask", - "ph": "mdi:ph", "rx": "mdi:test-tube", "waterLevel": "mdi:waves", "waterTemp": "mdi:coolant-temperature", @@ -69,6 +68,7 @@ class VariableSensorEntity(AsekoEntity, SensorEntity): self._attr_device_class = { "airTemp": SensorDeviceClass.TEMPERATURE, "waterTemp": SensorDeviceClass.TEMPERATURE, + "ph": SensorDeviceClass.PH, }.get(self._variable.type) @property From 6c39233e00bc8227d701c0020332af7555722e61 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:09:18 +0200 Subject: [PATCH 050/968] Correct youtube stream selector in media extractor (#101119) --- .../components/media_extractor/__init__.py | 13 ++++++++++--- .../media_extractor/snapshots/test_init.ambr | 8 ++++---- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index c6f899c4909..ec803bb1511 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -193,9 +193,16 @@ def get_best_stream(formats: list[dict[str, Any]]) -> str: def get_best_stream_youtube(formats: list[dict[str, Any]]) -> str: - """YouTube requests also include manifest files. + """YouTube responses also include files with only video or audio. - They don't have a filesize so we skip all formats without filesize. + So we filter on files with both audio and video codec. """ - return get_best_stream([format for format in formats if "filesize" in format]) + return get_best_stream( + [ + format + for format in formats + if format.get("acodec", "none") != "none" + and format.get("vcodec", "none") != "none" + ] + ) diff --git a/tests/components/media_extractor/snapshots/test_init.ambr b/tests/components/media_extractor/snapshots/test_init.ambr index 56162ca3040..d70c370b60c 100644 --- a/tests/components/media_extractor/snapshots/test_init.ambr +++ b/tests/components/media_extractor/snapshots/test_init.ambr @@ -6,7 +6,7 @@ ]), 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -87,7 +87,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZcu0DoOD-gaqg47wBA&ip=45.93.75.130&id=o-ALADwM6dkuCPsPIQiQ_ygvtMcP-xvew7ntgwcwtzWc4N&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783146&fvip=2&keepalive=yes&fexp=24007246&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRQIgT7VwysCFd3nXvaSSiJoVxkNj5jfMPSeitLsQmy_S1b4CIQDWFiZSIH3tV4hQRtHa9DbzdYL8RQpbKD_6aeNZ7t-3IA%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgHX4-RXGLMMOGBkRk1sGy7XnQ3wkahwF60RoxGmOabF0CIBpQjZOMeQQeqZX8JccDZAypFCP3chfxrtgzsfWCJJ0l', + 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805268&ei=tFgEZaHmFN2Px_AP2tSt2AQ&ip=45.93.75.130&id=o-AEj4DudORoGviGzjggo2mjXrQpjRh8L2BrOU-wekY859&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hne6nzy%2Csn-5hnekn7k&ms=au%2Crdu&mv=m&mvi=3&pl=22&initcwndbps=1957500&spc=UWF9f7_CV3gS4VV2VFq7hgxtUAyOlog&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783146&fvip=2&fexp=24007246&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhAO2IJciEtkI3PvYyVC_zkyo61I70wYJQXuGOMueeacrKAiA-UAdaJSlqqkfaa6QtqVnC_BJJZn7BXs85gh_fdbGoSg%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIgRCGi20K-ZvdukYkBZOidcHpGPUpIBOkw-jZGEncsKQECIQC5h-rCfQhDTQFqocOTtQXcNZVA54oIqjweF0mN5GpzFA%3D%3D', 'media_content_type': 'VIDEO', }) # --- @@ -105,7 +105,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr3---sn-5hne6nzy.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZcCPFpqOx_APj42f2Ao&ip=45.93.75.130&id=o-AJK-SE-1BW0w1_4zhkyevHLKWnD0vrRBPNot5eVH0ogM&itag=248&source=youtube&requiressl=yes&mh=7c&mm=31%2C26&mn=sn-5hne6nzy%2Csn-aigzrnld&ms=au%2Conr&mv=m&mvi=3&pl=22&initcwndbps=2095000&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=40874930&dur=212.040&lmt=1694044655610179&mt=1694783390&fvip=1&keepalive=yes&fexp=24007246%2C24362685&beids=24350017&c=IOS&txp=4535434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAJ-5AjGgFTR1w-qObfMtwCvs07CU5OUDG7bsNqAXrZMxAiEA4pJO9wj-ZQTqFHg5OP2_XZIJbog8NvY8BVSwENMwJfM%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRAIgMFD0fR8NqzBiP481IpIhnKJjW4Z2fLVfgKt5-OsWbxICICLr46c0ycoE_Ngo3heXuwdOWXs0nyZXegtnP5uHLJSb', + 'media_content_id': 'https://rr2---sn-5hnekn7k.googlevideo.com/videoplayback?expire=1694805294&ei=zlgEZaLeHcrlgAeFhLrYBA&ip=45.93.75.130&id=o-AFIa6Sil61_wuEFkUVhjKkr-0pyzj2cHi52leur2vR1j&itag=22&source=youtube&requiressl=yes&mh=7c&mm=31%2C29&mn=sn-5hnekn7k%2Csn-5hne6nzy&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=2095000&spc=UWF9f2Ob7Uhbkv1q69SZBYEqtijLGjs&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=212.091&lmt=1694045086815467&mt=1694783390&fvip=3&fexp=24007246%2C24362685&beids=24350018&c=ANDROID&txp=4532434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRAIgUiMmQEGPqT5Hb00S74LeTwF4PCN31mwbC_fUNSejdsQCIF2D11o2OXBxoLlOX00vyB1wfYLIo6dBnodrfYc9gH6y&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAI4QpoB0iBj-oMiNFMMdN0RN-u3nLji437a3jqTbhncSAiEAlvsdhJjG0-VZ2jCjyUZBtidBcUzYFwnk6qG7mIiNjCA%3D', 'media_content_type': 'VIDEO', }) # --- @@ -114,7 +114,7 @@ 'entity_id': 'media_player.bedroom', 'extra': dict({ }), - 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZcmcMdGVgQeatIDABA&ip=45.93.75.130&id=o-ANZGIl8-Lo8u8x_fU-l5VosaHna8zx8_6Ab0CCT-vzjQ&itag=243&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&vprv=1&svpuc=1&mime=video%2Fwebm&gir=yes&clen=104373&dur=9.009&lmt=1660945832037331&mt=1694796392&fvip=5&keepalive=yes&fexp=24007246&c=IOS&txp=4437434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cvprv%2Csvpuc%2Cmime%2Cgir%2Cclen%2Cdur%2Clmt&sig=AOq0QJ8wRgIhAMLnlCaLvJ2scyVr6qYrCp3rzn_Op9eerIVWyp62NXKIAiEAnswRfxH5KssHQAKETF2MPncVWX_eDgpTXBEHN589-Xo%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRQIhAN9Und25H4_kUjcAoZ_LVv0lAVTnPDkI-t5f7JJBA_jhAiAsXrF-84K_iBGiTwIwXS_eOlp5JPXxLEhyDj_cB8zdxQ%3D%3D', + 'media_content_id': 'https://rr2---sn-5hne6nzk.googlevideo.com/videoplayback?expire=1694818322&ei=sosEZfXrN8mrx_APirihiAo&ip=45.93.75.130&id=o-AK8fF61bmcIHhl_2kv1XxpCtdRixUPDqG0y6aunrwcZa&itag=18&source=youtube&requiressl=yes&mh=6Q&mm=31%2C29&mn=sn-5hne6nzk%2Csn-5hnednss&ms=au%2Crdu&mv=m&mvi=2&pl=22&initcwndbps=1868750&spc=UWF9f0JgCQlRLpY93JZnveUdoMCdkmY&vprv=1&svpuc=1&mime=video%2Fmp4&cnr=14&ratebypass=yes&dur=9.055&lmt=1665508348849369&mt=1694796392&fvip=5&fexp=24007246&beids=24350017&c=ANDROID&txp=4438434&sparams=expire%2Cei%2Cip%2Cid%2Citag%2Csource%2Crequiressl%2Cspc%2Cvprv%2Csvpuc%2Cmime%2Ccnr%2Cratebypass%2Cdur%2Clmt&sig=AOq0QJ8wRQIhALn143d2vS16xd_ndXj_rB8QOeHSCHC9YxSeOaRMF9eWAiAaYxqrRyV5bREBHLPCrs8Wk8Msm3hJrj11OJc2RIEyzw%3D%3D&lsparams=mh%2Cmm%2Cmn%2Cms%2Cmv%2Cmvi%2Cpl%2Cinitcwndbps&lsig=AG3C_xAwRgIhAKy1C4o9YUyi7o2_03UfJ8n8vXWgF4t8zB-4FXiAtJ5uAiEAh2chtgFo6quycJIs1kagkaa_AAQbEFrnFU1xEUDEqp4%3D', 'media_content_type': 'VIDEO', }) # --- From 8d972223d8f9aaa0f648c372e0a06c2d39a910d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:12:27 +0200 Subject: [PATCH 051/968] Add logging to media extractor to know the selected stream (#101117) --- homeassistant/components/media_extractor/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index ec803bb1511..328871cf78c 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -153,7 +153,7 @@ class MediaExtractor: except MEQueryException: _LOGGER.error("Wrong query format: %s", stream_query) return - + _LOGGER.debug("Selected the following stream: %s", stream_url) data = {k: v for k, v in self.call_data.items() if k != ATTR_ENTITY_ID} data[ATTR_MEDIA_CONTENT_ID] = stream_url From 591ffa8b680afb8dcf127e817f08c899c2f5ae79 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:12:56 +0200 Subject: [PATCH 052/968] Add device class pH to Poolsense (#101122) --- homeassistant/components/poolsense/sensor.py | 2 +- homeassistant/components/poolsense/strings.json | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/poolsense/sensor.py b/homeassistant/components/poolsense/sensor.py index ed120562374..c61196d9931 100644 --- a/homeassistant/components/poolsense/sensor.py +++ b/homeassistant/components/poolsense/sensor.py @@ -29,8 +29,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="pH", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, ), SensorEntityDescription( key="Battery", diff --git a/homeassistant/components/poolsense/strings.json b/homeassistant/components/poolsense/strings.json index 9ec67e223a1..02f186994e2 100644 --- a/homeassistant/components/poolsense/strings.json +++ b/homeassistant/components/poolsense/strings.json @@ -28,9 +28,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "last_seen": { "name": "Last seen" }, From edcf0b6333f77b571368a01321aa03d889d3a3e8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:13:24 +0200 Subject: [PATCH 053/968] Add device class pH to Flipr (#101121) --- homeassistant/components/flipr/sensor.py | 2 +- homeassistant/components/flipr/strings.json | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index a8618b2df87..66078c50c1a 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -25,8 +25,8 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="ph", - translation_key="ph", icon="mdi:pool", + device_class=SensorDeviceClass.PH, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( diff --git a/homeassistant/components/flipr/strings.json b/homeassistant/components/flipr/strings.json index 24557ff177b..235117afbd4 100644 --- a/homeassistant/components/flipr/strings.json +++ b/homeassistant/components/flipr/strings.json @@ -40,9 +40,6 @@ "chlorine": { "name": "Chlorine" }, - "ph": { - "name": "pH" - }, "water_temperature": { "name": "Water temperature" }, From 339b95c79f19699861aae8eacd6984c2b58794d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 19:30:35 +0200 Subject: [PATCH 054/968] Migrate WAQI unique id (#101112) * Migrate unique_id * Add docstring --- homeassistant/components/waqi/__init__.py | 16 +++++++++++++ homeassistant/components/waqi/sensor.py | 2 +- tests/components/waqi/test_sensor.py | 29 ++++++++++++++++++++++- 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index bc51a91364c..d3cf1af21a2 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -7,6 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import WAQIDataUpdateCoordinator @@ -17,6 +18,8 @@ PLATFORMS: list[Platform] = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up World Air Quality Index (WAQI) from a config entry.""" + await _migrate_unique_ids(hass, entry) + client = WAQIClient(session=async_get_clientsession(hass)) client.authenticate(entry.data[CONF_API_KEY]) @@ -35,3 +38,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _migrate_unique_ids(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Migrate pre-config flow unique ids.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + for reg_entry in registry_entries: + if isinstance(reg_entry.unique_id, int): + entity_registry.async_update_entity( + reg_entry.entity_id, new_unique_id=f"{reg_entry.unique_id}_air_quality" + ) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 0ad295ca5af..62170b329f4 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -159,7 +159,7 @@ class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Initialize the sensor.""" super().__init__(coordinator) self._attr_name = f"WAQI {self.coordinator.data.city.name}" - self._attr_unique_id = str(coordinator.data.station_id) + self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" @property def native_value(self) -> int | None: diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index ef434bcc544..7feb37a1b09 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState @@ -15,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -93,6 +94,32 @@ async def test_legacy_migration_already_imported( assert len(issue_registry.issues) == 1 +async def test_sensor_id_migration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migrating unique id for original sensor.""" + mock_config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + entity_registry.async_get_or_create( + SENSOR_DOMAIN, DOMAIN, 4584, config_entry=mock_config_entry + ) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.from_dict( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entities = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + assert len(entities) == 1 + assert hass.states.get("sensor.waqi_4584") + assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert entities[0].unique_id == "4584_air_quality" + + async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) From a19c6fe9ff76834c7b35a3d841a6a1ba7c074dfe Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 19:40:13 +0200 Subject: [PATCH 055/968] Revert pin on AlexaPy (#101123) --- homeassistant/package_constraints.txt | 4 ---- script/gen_requirements_all.py | 4 ---- 2 files changed, 8 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4f5868e2fff..d6f923f0047 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -174,10 +174,6 @@ pysnmp==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 -# Circular dependency on homeassistant itself -# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 -alexapy<1.27.0 - # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e87e8b16bcb..e27b681f998 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -176,10 +176,6 @@ pysnmp==1000000000.0.0 # breaks getmac due to them both sharing the same python package name inside 'getmac'. get-mac==1000000000.0.0 -# Circular dependency on homeassistant itself -# https://gitlab.com/keatontaylor/alexapy/-/blob/v1.27.0/pyproject.toml#L29 -alexapy<1.27.0 - # We want to skip the binary wheels for the 'charset-normalizer' packages. # They are build with mypyc, but causes issues with our wheel builder. # In order to do so, we need to constrain the version. From 257e608c1394e7ed8d18f96be006df01bf81f3a3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 Sep 2023 20:11:30 +0200 Subject: [PATCH 056/968] Use dataclass for mqtt TimestampedPublishMessage (#101124) --- homeassistant/components/mqtt/debug_info.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index 6b4b90586a7..41614a62f30 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -3,12 +3,11 @@ from __future__ import annotations from collections import deque from collections.abc import Callable +from dataclasses import dataclass import datetime as dt from functools import wraps from typing import TYPE_CHECKING, Any -import attr - from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.typing import DiscoveryInfoType @@ -49,15 +48,15 @@ def log_messages( return _decorator -@attr.s(slots=True, frozen=True) +@dataclass class TimestampedPublishMessage: """MQTT Message.""" - topic: str = attr.ib() - payload: PublishPayloadType = attr.ib() - qos: int = attr.ib() - retain: bool = attr.ib() - timestamp: dt.datetime = attr.ib(default=None) + topic: str + payload: PublishPayloadType + qos: int + retain: bool + timestamp: dt.datetime def log_message( From a5f87748781bd4536fd94814d1ac9c498dd2e9ab Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 Sep 2023 20:54:24 +0200 Subject: [PATCH 057/968] Use cached_property for legacy device_tracker type (#101125) --- homeassistant/components/device_tracker/legacy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b428018cd9e..7c12a2d8777 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -12,6 +12,7 @@ import attr import voluptuous as vol from homeassistant import util +from homeassistant.backports.functools import cached_property from homeassistant.components import zone from homeassistant.config import async_log_exception, load_yaml_config_file from homeassistant.const import ( @@ -262,7 +263,7 @@ class DeviceTrackerPlatform: platform: ModuleType = attr.ib() config: dict = attr.ib() - @property + @cached_property def type(self) -> str | None: """Return platform type.""" methods, platform_type = self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY From 1b05418647467ea7ae51b11fe80bb1572b4fbd57 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 29 Sep 2023 20:57:53 +0200 Subject: [PATCH 058/968] Bump aiowaqi to 1.1.1 (#101129) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 76e25225b7d..7b6bd3b8592 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==1.1.0"] + "requirements": ["aiowaqi==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 32954cf7001..c3c28dff73e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.0 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ae06b222f5..cba22b8a54d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.0 +aiowaqi==1.1.1 # homeassistant.components.watttime aiowatttime==0.1.1 From 26ba10f4e4bf162074e9e04cf3e428290638ecf2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 Sep 2023 21:07:30 +0200 Subject: [PATCH 059/968] Use dataclass for stream segment Part (#101128) * Use data class for stream segement Part * use slots --- homeassistant/components/stream/core.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 6b8e6c44a1c..bf66ef729bd 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable +from dataclasses import dataclass import datetime from enum import IntEnum import logging @@ -70,14 +71,14 @@ STREAM_SETTINGS_NON_LL_HLS = StreamSettings( ) -@attr.s(slots=True) +@dataclass(slots=True) class Part: """Represent a segment part.""" - duration: float = attr.ib() - has_keyframe: bool = attr.ib() + duration: float + has_keyframe: bool # video data (moof+mdat) - data: bytes = attr.ib() + data: bytes @attr.s(slots=True) From 581a0456172f9a1705efd64d475ab438e39d89c6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 29 Sep 2023 21:19:11 +0200 Subject: [PATCH 060/968] Trigger Wheels builds in more cases (#101126) --- .github/workflows/wheels.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 85912623f61..dd153299bce 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -10,8 +10,10 @@ on: - dev - rc paths: - - "requirements.txt" + - ".github/workflows/wheels.yml" + - "homeassistant/package_constraints.txt" - "requirements_all.txt" + - "requirements.txt" concurrency: group: ${{ github.workflow }}-${{ github.ref_name}} From 1546dee36e2c6eada67d1f99df6a37694ae38651 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 Sep 2023 21:36:54 +0200 Subject: [PATCH 061/968] Fix zha CI test might fail on changing time (#101134) --- tests/components/zha/test_websocket_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index b0e15a01318..d914c88c0c2 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -6,6 +6,7 @@ from copy import deepcopy from typing import TYPE_CHECKING from unittest.mock import ANY, AsyncMock, MagicMock, call, patch +from freezegun import freeze_time import pytest import voluptuous as vol import zigpy.backups @@ -227,6 +228,7 @@ async def test_device_cluster_commands(zha_client) -> None: assert command[TYPE] is not None +@freeze_time("2023-09-23 20:16:00+00:00") async def test_list_devices(zha_client) -> None: """Test getting ZHA devices.""" await zha_client.send_json({ID: 5, TYPE: "zha/devices"}) From facdc5e86246118d4a0e3861edba6959e1a3dcdc Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 29 Sep 2023 23:16:59 +0200 Subject: [PATCH 062/968] Fix - Make sure logging is in time in sonos CI test (#101109) Make sure logging is in time in sonos CI test --- tests/components/sonos/test_init.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index a3f74127283..f6b4db84630 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,5 +1,6 @@ """Tests for the Sonos config flow.""" import asyncio +from datetime import timedelta import logging from unittest.mock import Mock, patch @@ -17,9 +18,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util from .conftest import MockSoCo, SoCoMockFactory +from tests.common import async_fire_time_changed + async def test_creating_entry_sets_up_media_player( hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo @@ -322,16 +326,19 @@ async def test_async_poll_manual_hosts_5( # Speed up manual discovery interval so second iteration runs sooner mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) - await _setup_hass(hass) - - assert "media_player.bedroom" in entity_registry.entities - assert "media_player.living_room" in entity_registry.entities - with caplog.at_level(logging.DEBUG): caplog.clear() - await speaker_1_activity.event.wait() - await speaker_2_activity.event.wait() + + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=0.5)) await hass.async_block_till_done() + await asyncio.gather( + *[speaker_1_activity.event.wait(), speaker_2_activity.event.wait()] + ) assert speaker_1_activity.call_count == 1 assert speaker_2_activity.call_count == 1 assert "Activity on Living Room" in caplog.text From a1d632c5d1bd0c6f72ea368d71e62ddf7b8e840a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 29 Sep 2023 22:04:00 -0500 Subject: [PATCH 063/968] Bump plexapi to 4.15.3 (#101088) * Bump plexapi to 4.15.3 * Update tests for updated account endpoint * Update tests for updated resources endpoint * Switch to non-web client fixture * Set __qualname__ attribute for new library behavior --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plex/conftest.py | 10 +++- .../fixtures/player_plexhtpc_resources.xml | 3 ++ .../plex/fixtures/plextv_account.xml | 23 +++++---- .../fixtures/plextv_resources_one_server.xml | 40 +++++++++------- .../fixtures/plextv_resources_two_servers.xml | 48 +++++++++++-------- tests/components/plex/test_config_flow.py | 12 ++--- tests/components/plex/test_init.py | 10 ++-- tests/components/plex/test_media_players.py | 4 +- tests/components/plex/test_media_search.py | 11 ++++- tests/components/plex/test_playback.py | 40 ++++++++++++---- tests/components/plex/test_sensor.py | 30 ++++++++++-- tests/components/plex/test_services.py | 6 ++- 15 files changed, 159 insertions(+), 84 deletions(-) create mode 100644 tests/components/plex/fixtures/player_plexhtpc_resources.xml diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index bc0c54c49bf..6cf94793173 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.13.2", + "PlexAPI==4.15.3", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index c3c28dff73e..df05ddc12a6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -46,7 +46,7 @@ Mastodon.py==1.5.1 Pillow==10.0.0 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cba22b8a54d..fddeddd33c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ HATasmota==0.7.3 Pillow==10.0.0 # homeassistant.components.plex -PlexAPI==4.13.2 +PlexAPI==4.15.3 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index e4bf61ccd94..78a3b7387ea 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -232,6 +232,12 @@ def player_plexweb_resources_fixture(): return load_fixture("plex/player_plexweb_resources.xml") +@pytest.fixture(name="player_plexhtpc_resources", scope="session") +def player_plexhtpc_resources_fixture(): + """Load resources payload for a Plex HTPC player and return it.""" + return load_fixture("plex/player_plexhtpc_resources.xml") + + @pytest.fixture(name="playlists", scope="session") def playlists_fixture(): """Load payload for all playlists and return it.""" @@ -450,8 +456,8 @@ def mock_plex_calls( """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) requests_mock.get("https://plex.tv/api/invites/requested", text=empty_payload) - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) url = plex_server_url(entry) diff --git a/tests/components/plex/fixtures/player_plexhtpc_resources.xml b/tests/components/plex/fixtures/player_plexhtpc_resources.xml new file mode 100644 index 00000000000..6cc9cc0afbd --- /dev/null +++ b/tests/components/plex/fixtures/player_plexhtpc_resources.xml @@ -0,0 +1,3 @@ + + + diff --git a/tests/components/plex/fixtures/plextv_account.xml b/tests/components/plex/fixtures/plextv_account.xml index 32d6eec7c2d..b47896de577 100644 --- a/tests/components/plex/fixtures/plextv_account.xml +++ b/tests/components/plex/fixtures/plextv_account.xml @@ -1,15 +1,18 @@ - - - + + + + + + + + + - - - - testuser - testuser@email.com - 2000-01-01 12:34:56 UTC - faketoken + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_one_server.xml b/tests/components/plex/fixtures/plextv_resources_one_server.xml index ff2e458ff24..75b7e54b7e6 100644 --- a/tests/components/plex/fixtures/plextv_resources_one_server.xml +++ b/tests/components/plex/fixtures/plextv_resources_one_server.xml @@ -1,18 +1,22 @@ - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/fixtures/plextv_resources_two_servers.xml b/tests/components/plex/fixtures/plextv_resources_two_servers.xml index 7da5df4c1df..f14b55fe161 100644 --- a/tests/components/plex/fixtures/plextv_resources_two_servers.xml +++ b/tests/components/plex/fixtures/plextv_resources_two_servers.xml @@ -1,21 +1,27 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index beb454e2e9c..235596715f4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -143,7 +143,7 @@ async def test_no_servers_found( current_request_with_host: None, ) -> None: """Test when no servers are on an account.""" - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -225,7 +225,7 @@ async def test_multiple_servers_with_selection( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) with patch("plexauth.PlexAuth.initiate_auth"), patch( @@ -289,7 +289,7 @@ async def test_adding_last_unconfigured_server( assert result["step_id"] == "user" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -346,9 +346,9 @@ async def test_all_available_servers_configured( assert result["type"] == FlowResultType.FORM assert result["step_id"] == "user" - requests_mock.get("https://plex.tv/users/account", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) @@ -776,7 +776,7 @@ async def test_reauth_multiple_servers_available( ) -> None: """Test setup and reauthorization of a Plex token when multiple servers are available.""" requests_mock.get( - "https://plex.tv/api/resources", + "https://plex.tv/api/v2/resources", text=plextv_resources_two_servers, ) diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index bc43a1e0d89..6e1043b5c52 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -231,7 +231,7 @@ async def test_setup_when_certificate_changed( # Test with account failure requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) old_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(old_entry.entry_id) is False @@ -241,8 +241,8 @@ async def test_setup_when_certificate_changed( await hass.config_entries.async_unload(old_entry.entry_id) # Test with no servers found - requests_mock.get("https://plex.tv/users/account", text=plextv_account) - requests_mock.get("https://plex.tv/api/resources", text=empty_payload) + requests_mock.get("https://plex.tv/api/v2/user", text=plextv_account) + requests_mock.get("https://plex.tv/api/v2/resources", text=empty_payload) assert await hass.config_entries.async_setup(old_entry.entry_id) is False await hass.async_block_till_done() @@ -252,7 +252,7 @@ async def test_setup_when_certificate_changed( # Test with success new_url = PLEX_DIRECT_URL - requests_mock.get("https://plex.tv/api/resources", text=plextv_resources) + requests_mock.get("https://plex.tv/api/v2/resources", text=plextv_resources) for resource_url in [new_url, "http://1.2.3.4:32400"]: requests_mock.get(resource_url, text=plex_server_default) requests_mock.get(f"{new_url}/accounts", text=plex_server_accounts) @@ -287,7 +287,7 @@ async def test_bad_token_with_tokenless_server( ) -> None: """Test setup with a bad token and a server with token auth disabled.""" requests_mock.get( - "https://plex.tv/users/account", status_code=HTTPStatus.UNAUTHORIZED + "https://plex.tv/api/v2/user", status_code=HTTPStatus.UNAUTHORIZED ) await setup_plex_server() diff --git a/tests/components/plex/test_media_players.py b/tests/components/plex/test_media_players.py index 27fea36e3b0..e9efc945f71 100644 --- a/tests/components/plex/test_media_players.py +++ b/tests/components/plex/test_media_players.py @@ -12,10 +12,10 @@ async def test_plex_tv_clients( entry, setup_plex_server, requests_mock: requests_mock.Mocker, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test getting Plex clients from plex.tv.""" - requests_mock.get("/resources", text=player_plexweb_resources) + requests_mock.get("/resources", text=player_plexhtpc_resources) with patch("plexapi.myplex.MyPlexResource.connect", side_effect=NotFound): await setup_plex_server() diff --git a/tests/components/plex/test_media_search.py b/tests/components/plex/test_media_search.py index 0cc94134f1c..21b50724786 100644 --- a/tests/components/plex/test_media_search.py +++ b/tests/components/plex/test_media_search.py @@ -70,7 +70,10 @@ async def test_media_lookups( ) assert "Library 'Not a Library' not found in" in str(excinfo.value) - with patch("plexapi.library.LibrarySection.search") as search: + with patch( + "plexapi.library.LibrarySection.search", + __qualname__="search", + ) as search: await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, @@ -261,7 +264,11 @@ async def test_media_lookups( with pytest.raises(MediaNotFound) as excinfo: payload = '{"library_name": "Movies", "title": "Not a Movie"}' - with patch("plexapi.library.LibrarySection.search", side_effect=BadRequest): + with patch( + "plexapi.library.LibrarySection.search", + side_effect=BadRequest, + __qualname__="search", + ): await hass.services.async_call( MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, diff --git a/tests/components/plex/test_playback.py b/tests/components/plex/test_playback.py index c9dba4e4aca..9ea684256c4 100644 --- a/tests/components/plex/test_playback.py +++ b/tests/components/plex/test_playback.py @@ -49,14 +49,14 @@ async def test_media_player_playback( setup_plex_server, requests_mock: requests_mock.Mocker, playqueue_created, - player_plexweb_resources, + player_plexhtpc_resources, ) -> None: """Test playing media on a Plex media_player.""" - requests_mock.get("http://1.2.3.5:32400/resources", text=player_plexweb_resources) + requests_mock.get("http://1.2.3.6:32400/resources", text=player_plexhtpc_resources) await setup_plex_server() - media_player = "media_player.plex_plex_web_chrome" + media_player = "media_player.plex_plex_htpc_for_mac_plex_htpc" requests_mock.post("/playqueues", text=playqueue_created) playmedia_mock = requests_mock.get( "/player/playback/playMedia", status_code=HTTPStatus.OK @@ -65,7 +65,9 @@ async def test_media_player_playback( # Test media lookup failure payload = '{"library_name": "Movies", "title": "Movie 1" }' with patch( - "plexapi.library.LibrarySection.search", return_value=None + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", ), pytest.raises(HomeAssistantError) as excinfo: await hass.services.async_call( MP_DOMAIN, @@ -86,7 +88,11 @@ async def test_media_player_playback( # Test movie success movies = [movie1] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -101,7 +107,11 @@ async def test_media_player_playback( # Test movie success with resume playmedia_mock.reset() - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -163,7 +173,11 @@ async def test_media_player_playback( # Test multiple choices with exact match playmedia_mock.reset() movies = [movie1, movie2] - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -181,7 +195,11 @@ async def test_media_player_playback( movies = [movie2, movie3] with pytest.raises(HomeAssistantError) as excinfo: payload = '{"library_name": "Movies", "title": "Movie" }' - with patch("plexapi.library.LibrarySection.search", return_value=movies): + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ): await hass.services.async_call( MP_DOMAIN, SERVICE_PLAY_MEDIA, @@ -197,7 +215,11 @@ async def test_media_player_playback( # Test multiple choices with allow_multiple movies = [movie1, movie2, movie3] - with patch("plexapi.library.LibrarySection.search", return_value=movies), patch( + with patch( + "plexapi.library.LibrarySection.search", + return_value=movies, + __qualname__="search", + ), patch( "homeassistant.components.plex.server.PlexServer.create_playqueue" ) as mock_create_playqueue: await hass.services.async_call( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 9c73bf9f915..5b9729792f4 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -129,7 +129,11 @@ async def test_library_sensor_values( ) media = [MockPlexTVEpisode()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -165,7 +169,11 @@ async def test_library_sensor_values( trigger_plex_update( mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD ) - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") @@ -200,7 +208,11 @@ async def test_library_sensor_values( ) media = [MockPlexMovie()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") @@ -210,7 +222,11 @@ async def test_library_sensor_values( # Test with clip media = [MockPlexClip()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): async_dispatcher_send( hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) ) @@ -236,7 +252,11 @@ async def test_library_sensor_values( ) media = [MockPlexMusic()] - with patch("plexapi.library.LibrarySection.recentlyAdded", return_value=media): + with patch( + "plexapi.library.LibrarySection.recentlyAdded", + return_value=media, + __qualname__="recentlyAdded", + ): await hass.async_block_till_done() library_music_sensor = hass.states.get("sensor.plex_server_1_library_music") diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py index a74b3e91460..dfd02bb1d3f 100644 --- a/tests/components/plex/test_services.py +++ b/tests/components/plex/test_services.py @@ -190,7 +190,11 @@ async def test_lookup_media_for_other_integrations( assert result.shuffle # Test with media not found - with patch("plexapi.library.LibrarySection.search", return_value=None): + with patch( + "plexapi.library.LibrarySection.search", + return_value=None, + __qualname__="search", + ): with pytest.raises(HomeAssistantError) as excinfo: process_plex_payload(hass, MediaType.MUSIC, CONTENT_ID_BAD_MEDIA) assert f"No {MediaType.MUSIC} results in 'Music' for" in str(excinfo.value) From 7b9c1c395362298fbea3e62e99a609a0246550b5 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 29 Sep 2023 23:05:33 -0400 Subject: [PATCH 064/968] Fix zwave_js firmware update logic (#101143) * Fix zwave_js firmware update logic * add comment * tweak implementation for ssame outcome --- homeassistant/components/zwave_js/update.py | 12 ++++++++---- tests/components/zwave_js/test_update.py | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 3dedd8bf370..6efae29e46e 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -211,11 +211,15 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): return try: - available_firmware_updates = ( - await self.driver.controller.async_get_available_firmware_updates( - self.node, API_KEY_FIRMWARE_UPDATE_SERVICE + # Retrieve all firmware updates including non-stable ones but filter + # non-stable channels out + available_firmware_updates = [ + update + for update in await self.driver.controller.async_get_available_firmware_updates( + self.node, API_KEY_FIRMWARE_UPDATE_SERVICE, True ) - ) + if update.channel == "stable" + ] except FailedZWaveCommand as err: LOGGER.debug( "Failed to get firmware updates for node %s: %s", diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 4c3aa9f5499..46dca7a35ec 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -87,6 +87,24 @@ FIRMWARE_UPDATES = { "rfRegion": 1, }, }, + # This firmware update should never show because it's in the beta channel + { + "version": "999.999.999", + "changelog": "blah 3", + "channel": "beta", + "files": [ + {"target": 0, "url": "https://example3.com", "integrity": "sha3"} + ], + "downgrade": True, + "normalizedVersion": "999.999.999", + "device": { + "manufacturerId": 1, + "productType": 2, + "productId": 3, + "firmwareVersion": "0.4.4", + "rfRegion": 1, + }, + }, ] } From 542ab2dd76dc0285aa71fa235c9f76ef86e281b0 Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Sat, 30 Sep 2023 06:06:41 +0300 Subject: [PATCH 065/968] Fix ignored argument in service call for demo climate (#101137) Fix service call for demo climate --- homeassistant/components/demo/climate.py | 3 +++ tests/components/demo/test_climate.py | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index 6639c125653..b0a53909a46 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any from homeassistant.components.climate import ( + ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, ClimateEntity, @@ -258,6 +259,8 @@ class DemoClimate(ClimateEntity): ): self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + if kwargs.get(ATTR_HVAC_MODE) is not None: + self._hvac_mode = HVACMode(str(kwargs.get(ATTR_HVAC_MODE))) self.async_write_ha_state() async def async_set_humidity(self, humidity: int) -> None: diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py index fa87c439a4d..69e385ce242 100644 --- a/tests/components/demo/test_climate.py +++ b/tests/components/demo/test_climate.py @@ -198,6 +198,28 @@ async def test_set_target_temp_range_bad_attr(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 24.0 +async def test_set_temp_with_hvac_mode(hass: HomeAssistant) -> None: + """Test the setting of the hvac_mode in set_temperature.""" + state = hass.states.get(ENTITY_CLIMATE) + assert state.attributes.get(ATTR_TEMPERATURE) == 21 + assert state.state == HVACMode.COOL + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: ENTITY_CLIMATE, + ATTR_TEMPERATURE: 23, + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get(ENTITY_CLIMATE) + assert state.state == HVACMode.OFF + assert state.attributes.get(ATTR_TEMPERATURE) == 23 + + async def test_set_target_humidity_bad_attr(hass: HomeAssistant) -> None: """Test setting the target humidity without required attribute.""" state = hass.states.get(ENTITY_CLIMATE) From 6e3c704a338b58c14b7ed81cf29bc357de2234fe Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 30 Sep 2023 05:07:11 +0200 Subject: [PATCH 066/968] Return None when value is not known in OpenHardwareMonitor (#101127) * Return None when value is not known * Add to coverage --- .coveragerc | 1 + homeassistant/components/openhardwaremonitor/sensor.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.coveragerc b/.coveragerc index 2f899999f41..533fd8de18d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -898,6 +898,7 @@ omit = homeassistant/components/opengarage/cover.py homeassistant/components/opengarage/entity.py homeassistant/components/opengarage/sensor.py + homeassistant/components/openhardwaremonitor/sensor.py homeassistant/components/openhome/__init__.py homeassistant/components/openhome/const.py homeassistant/components/openhome/media_player.py diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py index 70dbbd38fc8..4206bc72c1d 100644 --- a/homeassistant/components/openhardwaremonitor/sensor.py +++ b/homeassistant/components/openhardwaremonitor/sensor.py @@ -79,6 +79,8 @@ class OpenHardwareMonitorDevice(SensorEntity): @property def native_value(self): """Return the state of the device.""" + if self.value == "-": + return None return self.value @property From e6c9a82b5f62e7abe2c7043bc29e8fe3e383a1fd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 30 Sep 2023 02:12:44 -0400 Subject: [PATCH 067/968] Improve conditional on unload (#101149) --- homeassistant/components/zwave_js/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index b9a26630406..c9decc92a67 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -915,6 +915,7 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" info = hass.data[DOMAIN][entry.entry_id] + client: ZwaveClient = info[DATA_CLIENT] driver_events: DriverEvents = info[DATA_DRIVER_EVENTS] tasks: list[Coroutine] = [ @@ -925,8 +926,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: unload_ok = all(await asyncio.gather(*tasks)) if tasks else True - if hasattr(driver_events, "driver"): - await async_disable_server_logging_if_needed(hass, entry, driver_events.driver) + if client.connected and client.driver: + await async_disable_server_logging_if_needed(hass, entry, client.driver) if DATA_CLIENT_LISTEN_TASK in info: await disconnect_client(hass, entry) From d40a08958de3be600f1c058fd85d9bfe18dde711 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Sat, 30 Sep 2023 15:46:30 +0800 Subject: [PATCH 068/968] Use dataclasses instead of attrs in stream (#101148) --- homeassistant/components/stream/core.py | 41 +++++++++++------------ homeassistant/components/stream/worker.py | 6 ++-- tests/components/stream/common.py | 2 +- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index bf66ef729bd..5768f886adb 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -4,14 +4,13 @@ from __future__ import annotations import asyncio from collections import deque from collections.abc import Callable, Coroutine, Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field import datetime from enum import IntEnum import logging from typing import TYPE_CHECKING, Any from aiohttp import web -import attr import numpy as np from homeassistant.components.http.view import HomeAssistantView @@ -51,15 +50,15 @@ class Orientation(IntEnum): ROTATE_RIGHT = 8 -@attr.s(slots=True) +@dataclass(slots=True) class StreamSettings: """Stream settings.""" - ll_hls: bool = attr.ib() - min_segment_duration: float = attr.ib() - part_target_duration: float = attr.ib() - hls_advance_part_limit: int = attr.ib() - hls_part_timeout: float = attr.ib() + ll_hls: bool + min_segment_duration: float + part_target_duration: float + hls_advance_part_limit: int + hls_part_timeout: float STREAM_SETTINGS_NON_LL_HLS = StreamSettings( @@ -81,29 +80,29 @@ class Part: data: bytes -@attr.s(slots=True) +@dataclass(slots=True) class Segment: """Represent a segment.""" - sequence: int = attr.ib() + sequence: int # the init of the mp4 the segment is based on - init: bytes = attr.ib() + init: bytes # For detecting discontinuities across stream restarts - stream_id: int = attr.ib() - start_time: datetime.datetime = attr.ib() - _stream_outputs: Iterable[StreamOutput] = attr.ib() - duration: float = attr.ib(default=0) - parts: list[Part] = attr.ib(factory=list) + stream_id: int + start_time: datetime.datetime + _stream_outputs: Iterable[StreamOutput] + duration: float = 0 + parts: list[Part] = field(default_factory=list) # Store text of this segment's hls playlist for reuse # Use list[str] for easy appends - hls_playlist_template: list[str] = attr.ib(factory=list) - hls_playlist_parts: list[str] = attr.ib(factory=list) + hls_playlist_template: list[str] = field(default_factory=list) + hls_playlist_parts: list[str] = field(default_factory=list) # Number of playlist parts rendered so far - hls_num_parts_rendered: int = attr.ib(default=0) + hls_num_parts_rendered: int = 0 # Set to true when all the parts are rendered - hls_playlist_complete: bool = attr.ib(default=False) + hls_playlist_complete: bool = False - def __attrs_post_init__(self) -> None: + def __post_init__(self) -> None: """Run after init.""" for output in self._stream_outputs: output.put(self) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index cc4970c8a5e..3d27637c989 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -4,13 +4,13 @@ from __future__ import annotations from collections import defaultdict, deque from collections.abc import Callable, Generator, Iterator, Mapping import contextlib +from dataclasses import fields import datetime from io import SEEK_END, BytesIO import logging from threading import Event from typing import Any, Self, cast -import attr import av from homeassistant.core import HomeAssistant @@ -283,7 +283,7 @@ class StreamMuxer: init=read_init(self._memory_file), # Fetch the latest StreamOutputs, which may have changed since the # worker started. - stream_outputs=self._stream_state.outputs, + _stream_outputs=self._stream_state.outputs, start_time=self._start_time, ) self._memory_file_pos = self._memory_file.tell() @@ -537,7 +537,7 @@ def stream_worker( audio_stream = None # Disable ll-hls for hls inputs if container.format.name == "hls": - for field in attr.fields(StreamSettings): + for field in fields(StreamSettings): setattr( stream_settings, field.name, diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py index 7ea583c0ec3..ae4a4fc2d9d 100644 --- a/tests/components/stream/common.py +++ b/tests/components/stream/common.py @@ -24,7 +24,7 @@ DefaultSegment = partial( init=None, stream_id=0, start_time=FAKE_TIME, - stream_outputs=[], + _stream_outputs=[], ) AUDIO_SAMPLE_RATE = 8000 From 47ecce487321c25266adf5992cc04444b78a8938 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 30 Sep 2023 10:23:10 +0200 Subject: [PATCH 069/968] Allow deleting entity entries from entity_registry.async_migrate_entries (#101094) * Allow deleting entity entries from entity_registry.async_migrate_entries * Explicitly return None in tests --- homeassistant/helpers/entity_registry.py | 10 +++- tests/helpers/test_entity_registry.py | 66 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 42de4749215..bd3077c1d59 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1323,12 +1323,18 @@ async def async_migrate_entries( config_entry_id: str, entry_callback: Callable[[RegistryEntry], dict[str, Any] | None], ) -> None: - """Migrator of unique IDs.""" + """Migrate entity registry entries which belong to a config entry. + + Can be used as a migrator of unique_ids or to update other entity registry data. + Can also be used to remove duplicated entity registry entries. + """ ent_reg = async_get(hass) - for entry in ent_reg.entities.values(): + for entry in list(ent_reg.entities.values()): if entry.config_entry_id != config_entry_id: continue + if not ent_reg.entities.get_entry(entry.id): + continue updates = entry_callback(entry) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index f62addb9a64..4bf03b4d39b 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1684,3 +1684,69 @@ async def test_restore_entity(hass, update_events, freezer): assert update_events[11] == {"action": "remove", "entity_id": "light.hue_1234"} # Restore entities the 3rd time assert update_events[12] == {"action": "create", "entity_id": "light.hue_1234"} + + +async def test_async_migrate_entry_delete_self(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + entry3 = registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry1.entity_id) + return None + if entity_entry == entry2: + return {"original_name": "Entry 2 renamed"} + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id, entry2.entity_id} + assert not registry.async_is_registered(entry1.entity_id) + entry2 = registry.async_get(entry2.entity_id) + assert entry2.original_name == "Entry 2 renamed" + assert registry.async_get(entry3.entity_id) is entry3 + + +async def test_async_migrate_entry_delete_other(hass): + """Test async_migrate_entry.""" + registry = er.async_get(hass) + config_entry1 = MockConfigEntry(domain="test1") + config_entry2 = MockConfigEntry(domain="test2") + entry1 = registry.async_get_or_create( + "light", "hue", "1234", config_entry=config_entry1, original_name="Entry 1" + ) + entry2 = registry.async_get_or_create( + "light", "hue", "5678", config_entry=config_entry1, original_name="Entry 2" + ) + registry.async_get_or_create( + "light", "hue", "90AB", config_entry=config_entry2, original_name="Entry 3" + ) + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + entries.add(entity_entry.entity_id) + if entity_entry == entry1: + registry.async_remove(entry2.entity_id) + return None + if entity_entry == entry2: + # We should not get here + pytest.fail() + return None + + entries = set() + await er.async_migrate_entries(hass, config_entry1.entry_id, _async_migrator) + assert entries == {entry1.entity_id} + assert not registry.async_is_registered(entry2.entity_id) From 9444a474ecb038f38e05e4cc5d408e5c041f33c4 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Sat, 30 Sep 2023 10:43:07 +0200 Subject: [PATCH 070/968] Stop the Home Assistant Core container by default (#101105) --- rootfs/etc/services.d/home-assistant/finish | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 057957a9c03..ae5b17e171a 100755 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -18,13 +18,11 @@ elif [[ ${APP_EXIT_CODE} -eq ${SIGNAL_EXIT_CODE} ]]; then NEW_EXIT_CODE=$((128 + SIGNAL_NO)) echo ${NEW_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - - if [[ ${SIGNAL_NO} -eq ${SIGTERM} ]]; then - /run/s6/basedir/bin/halt - fi else bashio::log.info "Home Assistant Core service shutdown" echo ${APP_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - /run/s6/basedir/bin/halt fi + +# Make sure to stop the container +/run/s6/basedir/bin/halt From 42eb849caebf4ecfc0ad28ebee8a6f1fec0602d7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 30 Sep 2023 10:44:46 +0200 Subject: [PATCH 071/968] Use dataclass for abode system class (#101138) --- homeassistant/components/abode/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 490561c7485..4e4b6a9561d 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -1,6 +1,7 @@ """Support for the Abode Security System.""" from __future__ import annotations +from dataclasses import dataclass, field from functools import partial from jaraco.abode.automation import Automation as AbodeAuto @@ -25,7 +26,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, entity from homeassistant.helpers.device_registry import DeviceInfo @@ -71,15 +72,14 @@ PLATFORMS = [ ] +@dataclass class AbodeSystem: """Abode System class.""" - def __init__(self, abode: Abode, polling: bool) -> None: - """Initialize the system.""" - self.abode = abode - self.polling = polling - self.entity_ids: set[str | None] = set() - self.logout_listener = None + abode: Abode + polling: bool + entity_ids: set[str | None] = field(default_factory=set) + logout_listener: CALLBACK_TYPE | None = None async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: From 25855a3ccbfa411de05fbfc980200b92cac18d39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 30 Sep 2023 21:17:49 +0200 Subject: [PATCH 072/968] Update home-assistant/wheels to 2023.09.3 (#101165) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index dd153299bce..a2a1fc924fd 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.09.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -180,7 +180,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.09.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -194,7 +194,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.09.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -208,7 +208,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.09.1 + uses: home-assistant/wheels@2023.09.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From fe30c019b61359ba1b91ab7ca575dcaf8bdd856e Mon Sep 17 00:00:00 2001 From: Tereza Tomcova Date: Sat, 30 Sep 2023 22:53:58 +0300 Subject: [PATCH 073/968] Bump PySwitchbot to 0.40.1 (#101164) --- 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 e685d1de806..e835a2f4aca 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.40.0"] + "requirements": ["PySwitchbot==0.40.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index df05ddc12a6..ba9ae32e797 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.switchmate PySwitchmate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fddeddd33c0..c775b54b0cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -87,7 +87,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -PySwitchbot==0.40.0 +PySwitchbot==0.40.1 # homeassistant.components.syncthru PySyncThru==0.7.10 From bd2fee289dc4e48ba75dd6ffb4ee22f633b412af Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 30 Sep 2023 16:56:39 -0700 Subject: [PATCH 074/968] Update Fitbit integration to allow UI based configuration (#100897) * Cleanup fitbit sensor API parsing * Remove API code that is not used yet * Configuration flow for fitbit * Code cleanup after manual review * Streamline code for review * Use scopes to determine which entities to enable * Use set for entity comparisons * Apply fitbit string pr feedback * Improve fitbit configuration flow error handling * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Fix typo in more places * Revert typing import * Revert custom domain back to default * Add additional config flow tests * Add breaks_in_ha_version to repair issues * Update homeassistant/components/fitbit/api.py Co-authored-by: Martin Hjelmare * Increase test coverage for token refresh success case * Add breaks_in_ha_version for sensor issue * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Simplify translations, issue keys, and token refresh * Config flow test improvements * Simplify repair issue creation on fitbit import * Remove unused strings --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/__init__.py | 46 ++ homeassistant/components/fitbit/api.py | 89 +++- .../fitbit/application_credentials.py | 77 +++ .../components/fitbit/config_flow.py | 54 ++ homeassistant/components/fitbit/const.py | 13 + homeassistant/components/fitbit/manifest.json | 3 +- homeassistant/components/fitbit/sensor.py | 464 +++++++----------- homeassistant/components/fitbit/strings.json | 38 ++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/fitbit/conftest.py | 109 +++- tests/components/fitbit/test_config_flow.py | 315 ++++++++++++ tests/components/fitbit/test_init.py | 96 ++++ tests/components/fitbit/test_sensor.py | 182 ++++++- 15 files changed, 1188 insertions(+), 302 deletions(-) create mode 100644 homeassistant/components/fitbit/application_credentials.py create mode 100644 homeassistant/components/fitbit/config_flow.py create mode 100644 homeassistant/components/fitbit/strings.json create mode 100644 tests/components/fitbit/test_config_flow.py create mode 100644 tests/components/fitbit/test_init.py diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 04946f6386f..2a7b58d7d76 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1 +1,47 @@ """The fitbit component.""" + +import aiohttp + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up fitbit from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + fitbit_api = api.OAuthFitbitApi( + hass, session, unit_system=entry.data.get("unit_system") + ) + try: + await fitbit_api.async_get_access_token() + except aiohttp.ClientError as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = fitbit_api + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index bf287471292..9ebfbcf7188 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,11 +1,14 @@ """API for fitbit bound to Home Assistant OAuth.""" +from abc import ABC, abstractmethod import logging from typing import Any, cast from fitbit import Fitbit +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.unit_system import METRIC_SYSTEM from .const import FitbitUnitSystem @@ -13,32 +16,50 @@ from .model import FitbitDevice, FitbitProfile _LOGGER = logging.getLogger(__name__) +CONF_REFRESH_TOKEN = "refresh_token" +CONF_EXPIRES_AT = "expires_at" -class FitbitApi: - """Fitbit client library wrapper base class.""" + +class FitbitApi(ABC): + """Fitbit client library wrapper base class. + + This can be subclassed with different implementations for providing an access + token depending on the use case. + """ def __init__( self, hass: HomeAssistant, - client: Fitbit, unit_system: FitbitUnitSystem | None = None, ) -> None: """Initialize Fitbit auth.""" self._hass = hass self._profile: FitbitProfile | None = None - self._client = client self._unit_system = unit_system - @property - def client(self) -> Fitbit: - """Property to expose the underlying client library.""" - return self._client + @abstractmethod + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid token dictionary for the Fitbit API.""" + + async def _async_get_client(self) -> Fitbit: + """Get synchronous client library, called before each client request.""" + # Always rely on Home Assistant's token update mechanism which refreshes + # the data in the configuration entry. + token = await self.async_get_access_token() + return Fitbit( + client_id=None, + client_secret=None, + access_token=token[CONF_ACCESS_TOKEN], + refresh_token=token[CONF_REFRESH_TOKEN], + expires_at=float(token[CONF_EXPIRES_AT]), + ) async def async_get_user_profile(self) -> FitbitProfile: """Return the user profile from the API.""" if self._profile is None: + client = await self._async_get_client() response: dict[str, Any] = await self._hass.async_add_executor_job( - self._client.user_profile_get + client.user_profile_get ) _LOGGER.debug("user_profile_get=%s", response) profile = response["user"] @@ -73,8 +94,9 @@ class FitbitApi: async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" + client = await self._async_get_client() devices: list[dict[str, str]] = await self._hass.async_add_executor_job( - self._client.get_devices + client.get_devices ) _LOGGER.debug("get_devices=%s", devices) return [ @@ -90,17 +112,56 @@ class FitbitApi: async def async_get_latest_time_series(self, resource_type: str) -> dict[str, Any]: """Return the most recent value from the time series for the specified resource type.""" + client = await self._async_get_client() # Set request header based on the configured unit system - self._client.system = await self.async_get_unit_system() + client.system = await self.async_get_unit_system() def _time_series() -> dict[str, Any]: - return cast( - dict[str, Any], self._client.time_series(resource_type, period="7d") - ) + return cast(dict[str, Any], client.time_series(resource_type, period="7d")) response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] return dated_results[-1] + + +class OAuthFitbitApi(FitbitApi): + """Provide fitbit authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth_session: config_entry_oauth2_flow.OAuth2Session, + unit_system: FitbitUnitSystem | None = None, + ) -> None: + """Initialize OAuthFitbitApi.""" + super().__init__(hass, unit_system) + self._oauth_session = oauth_session + + async def async_get_access_token(self) -> dict[str, Any]: + """Return a valid access token for the Fitbit API.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token + + +class ConfigFlowFitbitApi(FitbitApi): + """Profile fitbit authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__( + self, + hass: HomeAssistant, + token: dict[str, Any], + ) -> None: + """Initialize ConfigFlowFitbitApi.""" + super().__init__(hass) + self._token = token + + async def async_get_access_token(self) -> dict[str, Any]: + """Return the token for the Fitbit API.""" + return self._token diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py new file mode 100644 index 00000000000..95a7cf799bf --- /dev/null +++ b/homeassistant/components/fitbit/application_credentials.py @@ -0,0 +1,77 @@ +"""application_credentials platform the fitbit integration. + +See https://dev.fitbit.com/build/reference/web-api/authorization/ for additional +details on Fitbit authorization. +""" + +import base64 +import logging +from typing import Any, cast + +from homeassistant.components.application_credentials import ( + AuthImplementation, + AuthorizationServer, + ClientCredential, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN + +_LOGGER = logging.getLogger(__name__) + + +class FitbitOAuth2Implementation(AuthImplementation): + """Local OAuth2 implementation for Fitbit. + + This implementation is needed to send the client id and secret as a Basic + Authorization header. + """ + + async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict: + """Resolve the authorization code to tokens.""" + session = async_get_clientsession(self.hass) + data = { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + resp = await session.post(self.token_url, data=data, headers=self._headers) + resp.raise_for_status() + return cast(dict, await resp.json()) + + async def _token_request(self, data: dict) -> dict: + """Make a token request.""" + session = async_get_clientsession(self.hass) + body = { + **data, + CONF_CLIENT_ID: self.client_id, + CONF_CLIENT_SECRET: self.client_secret, + } + resp = await session.post(self.token_url, data=body, headers=self._headers) + resp.raise_for_status() + return cast(dict, await resp.json()) + + @property + def _headers(self) -> dict[str, str]: + """Build necessary authorization headers.""" + basic_auth = base64.b64encode( + f"{self.client_id}:{self.client_secret}".encode() + ).decode() + return {"Authorization": f"Basic {basic_auth}"} + + +async def async_get_auth_implementation( + hass: HomeAssistant, auth_domain: str, credential: ClientCredential +) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: + """Return a custom auth implementation.""" + return FitbitOAuth2Implementation( + hass, + auth_domain, + credential, + AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ), + ) diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py new file mode 100644 index 00000000000..d391660df97 --- /dev/null +++ b/homeassistant/components/fitbit/config_flow.py @@ -0,0 +1,54 @@ +"""Config flow for fitbit.""" + +import logging +from typing import Any + +from fitbit.exceptions import HTTPException + +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN, OAUTH_SCOPES + +_LOGGER = logging.getLogger(__name__) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle fitbit OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, str]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH_SCOPES), + "prompt": "consent", + } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + + client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN]) + try: + profile = await client.async_get_user_profile() + except HTTPException as err: + _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) + return self.async_abort(reason="cannot_connect") + + await self.async_set_unique_id(profile.encoded_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=profile.full_name, data=data) + + async def async_step_import(self, data: dict[str, Any]) -> FlowResult: + """Handle import from YAML.""" + return await self.async_oauth_create_entry(data) diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 19734add07a..9c77ea79a4f 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -65,3 +65,16 @@ class FitbitUnitSystem(StrEnum): EN_GB = "en_GB" """Use United Kingdom units.""" + + +OAUTH2_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize" +OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token" +OAUTH_SCOPES = [ + "activity", + "heartrate", + "nutrition", + "profile", + "settings", + "sleep", + "weight", +] diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json index 510fe8da900..7739c7237f0 100644 --- a/homeassistant/components/fitbit/manifest.json +++ b/homeassistant/components/fitbit/manifest.json @@ -2,7 +2,8 @@ "domain": "fitbit", "name": "Fitbit", "codeowners": ["@allenporter"], - "dependencies": ["configurator", "http"], + "config_flow": true, + "dependencies": ["application_credentials", "http"], "documentation": "https://www.home-assistant.io/integrations/fitbit", "iot_class": "cloud_polling", "loggers": ["fitbit"], diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index e08f56e0e34..8fbd9a25474 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -7,17 +7,14 @@ from dataclasses import dataclass import datetime import logging import os -import time from typing import Any, Final, cast -from aiohttp.web import Request -from fitbit import Fitbit -from fitbit.api import FitbitOauth2Client -from oauthlib.oauth2.rfc6749.errors import MismatchingStateError, MissingTokenError import voluptuous as vol -from homeassistant.components import configurator -from homeassistant.components.http import HomeAssistantView +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorDeviceClass, @@ -25,9 +22,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_UNIT_SYSTEM, PERCENTAGE, UnitOfLength, @@ -35,11 +34,11 @@ from homeassistant.const import ( UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level -from homeassistant.helpers.json import save_json -from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import load_json_object @@ -54,8 +53,7 @@ from .const import ( CONF_MONITORED_RESOURCES, DEFAULT_CLOCK_FORMAT, DEFAULT_CONFIG, - FITBIT_AUTH_CALLBACK_PATH, - FITBIT_AUTH_START, + DOMAIN, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, FitbitUnitSystem, @@ -129,6 +127,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None + scope: str | None = None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -137,18 +136,22 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", ), FitbitSensorEntityDescription( key="activities/calories", name="Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", ), FitbitSensorEntityDescription( key="activities/caloriesBMR", name="Calories BMR", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/distance", @@ -157,6 +160,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope="activity", ), FitbitSensorEntityDescription( key="activities/elevation", @@ -164,12 +168,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope="activity", ), FitbitSensorEntityDescription( key="activities/floors", name="Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope="activity", ), FitbitSensorEntityDescription( key="activities/heart", @@ -177,6 +183,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), + scope="heartrate", ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -184,6 +191,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesLightlyActive", @@ -191,6 +199,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesSedentary", @@ -198,6 +207,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/minutesVeryActive", @@ -205,24 +215,30 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope="activity", ), FitbitSensorEntityDescription( key="activities/steps", name="Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope="activity", ), FitbitSensorEntityDescription( key="activities/tracker/activityCalories", name="Tracker Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/calories", name="Tracker Calories", native_unit_of_measurement="cal", icon="mdi:fire", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/distance", @@ -231,6 +247,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", @@ -238,12 +256,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/floors", name="Tracker Floors", native_unit_of_measurement="floors", icon="mdi:walk", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesFairlyActive", @@ -251,6 +273,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesLightlyActive", @@ -258,6 +282,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesSedentary", @@ -265,6 +291,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/minutesVeryActive", @@ -272,12 +300,16 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="activities/tracker/steps", name="Tracker Steps", native_unit_of_measurement="steps", icon="mdi:walk", + scope="activity", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/bmi", @@ -286,6 +318,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope="weight", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/fat", @@ -294,6 +328,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, + scope="weight", + entity_registry_enabled_default=False, ), FitbitSensorEntityDescription( key="body/weight", @@ -303,12 +339,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, unit_fn=_weight_unit, + scope="weight", ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", name="Awakenings Count", native_unit_of_measurement="times awaken", icon="mdi:sleep", + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/efficiency", @@ -316,6 +354,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAfterWakeup", @@ -323,6 +362,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAsleep", @@ -330,6 +370,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesAwake", @@ -337,6 +378,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/minutesToFallAsleep", @@ -344,6 +386,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), FitbitSensorEntityDescription( key="sleep/timeInBed", @@ -351,6 +394,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, + scope="sleep", ), ) @@ -359,18 +403,21 @@ SLEEP_START_TIME = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", + scope="sleep", ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", value_fn=_clock_format_12h, + scope="sleep", ) FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", icon="mdi:battery", + scope="settings", ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ @@ -397,88 +444,29 @@ PLATFORM_SCHEMA: Final = PARENT_PLATFORM_SCHEMA.extend( } ) - -def request_app_setup( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - config_path: str, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Assist user with configuring the Fitbit dev application.""" - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - error_msg = ( - f"You didn't correctly modify {FITBIT_CONFIG_FILE}, please try" - " again." - ) - - configurator.notify_errors(hass, _CONFIGURING["fitbit"], error_msg) - else: - setup_platform(hass, config, add_entities, discovery_info) - else: - setup_platform(hass, config, add_entities, discovery_info) - - try: - description = f"""Please create a Fitbit developer app at - https://dev.fitbit.com/apps/new. - For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}. - (Note: Your Home Assistant instance must be accessible via HTTPS.) - They will provide you a Client ID and secret. - These need to be saved into the file located at: {config_path}. - Then come back here and hit the below button. - """ - except NoURLAvailableError: - _LOGGER.error( - "Could not find an SSL enabled URL for your Home Assistant instance. " - "Fitbit requires that your Home Assistant instance is accessible via HTTPS" - ) - return - - submit = f"I have saved my Client ID and Client Secret into {FITBIT_CONFIG_FILE}." - - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption=submit, - description_image="/static/images/config_fitbit_app.png", - ) +# Only import configuration if it was previously created successfully with all +# of the following fields. +FITBIT_CONF_KEYS = [ + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + ATTR_ACCESS_TOKEN, + ATTR_REFRESH_TOKEN, + ATTR_LAST_SAVED_AT, +] -def request_oauth_completion(hass: HomeAssistant) -> None: - """Request user complete Fitbit OAuth2 flow.""" - if "fitbit" in _CONFIGURING: - configurator.notify_errors( - hass, _CONFIGURING["fitbit"], "Failed to register, please try again." - ) - - return - - def fitbit_configuration_callback(fields: list[dict[str, str]]) -> None: - """Handle configuration updates.""" - - start_url = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_START}" - - description = f"Please authorize Fitbit by visiting {start_url}" - - _CONFIGURING["fitbit"] = configurator.request_config( - hass, - "Fitbit", - fitbit_configuration_callback, - description=description, - submit_caption="I have authorized Fitbit.", - ) +def load_config_file(config_path: str) -> dict[str, Any] | None: + """Load existing valid fitbit.conf from disk for import.""" + if os.path.isfile(config_path): + config_file = load_json_object(config_path) + if config_file != DEFAULT_CONFIG and all( + key in config_file for key in FITBIT_CONF_KEYS + ): + return config_file + return None -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, @@ -486,182 +474,119 @@ def setup_platform( ) -> None: """Set up the Fitbit sensor.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) - if os.path.isfile(config_path): - config_file = load_json_object(config_path) - if config_file == DEFAULT_CONFIG: - request_app_setup( - hass, config, add_entities, config_path, discovery_info=None - ) - return + config_file = await hass.async_add_executor_job(load_config_file, config_path) + _LOGGER.debug("loaded config file: %s", config_file) + + if config_file is not None: + _LOGGER.debug("Importing existing fitbit.conf application credentials") + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], + "expires_at": config_file[ATTR_LAST_SAVED_AT], + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: - save_json(config_path, DEFAULT_CONFIG) - request_app_setup(hass, config, add_entities, config_path, discovery_info=None) - return + translation_key = "deprecated_yaml_no_import" - if "fitbit" in _CONFIGURING: - configurator.request_done(hass, _CONFIGURING.pop("fitbit")) + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2024.5.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key=translation_key, + ) - if ( - (access_token := config_file.get(ATTR_ACCESS_TOKEN)) is not None - and (refresh_token := config_file.get(ATTR_REFRESH_TOKEN)) is not None - and (expires_at := config_file.get(ATTR_LAST_SAVED_AT)) is not None - ): - authd_client = Fitbit( - config_file.get(CONF_CLIENT_ID), - config_file.get(CONF_CLIENT_SECRET), - access_token=access_token, - refresh_token=refresh_token, - expires_at=expires_at, - refresh_cb=lambda x: None, + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fitbit sensor platform.""" + + api: FitbitApi = hass.data[DOMAIN][entry.entry_id] + + # Note: This will only be one rpc since it will cache the user profile + (user_profile, unit_system) = await asyncio.gather( + api.async_get_user_profile(), api.async_get_unit_system() + ) + + clock_format = entry.data.get(CONF_CLOCK_FORMAT) + + # Originally entities were configured explicitly from yaml config. Newer + # configurations will infer which entities to enable based on the allowed + # scopes the user selected during OAuth. When creating entities based on + # scopes, some entities are disabled by default. + monitored_resources = entry.data.get(CONF_MONITORED_RESOURCES) + scopes = entry.data["token"].get("scope", "").split(" ") + + def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool: + """Determine if entity is enabled by default.""" + if monitored_resources is not None: + return description.key in monitored_resources + return False + + def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: + """Determine if an entity is allowed to be created.""" + if is_explicit_enable(description): + return True + return description.scope in scopes + + resource_list = [ + *FITBIT_RESOURCES_LIST, + SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + ] + + entities = [ + FitbitSensor( + api, + user_profile.encoded_id, + description, + units=description.unit_fn(unit_system), + enable_default_override=is_explicit_enable(description), ) - - if int(time.time()) - cast(int, expires_at) > 3600: - authd_client.client.refresh_token() - - api = FitbitApi(hass, authd_client, config[CONF_UNIT_SYSTEM]) - user_profile = asyncio.run_coroutine_threadsafe( - api.async_get_user_profile(), hass.loop - ).result() - unit_system = asyncio.run_coroutine_threadsafe( - api.async_get_unit_system(), hass.loop - ).result() - - clock_format = config[CONF_CLOCK_FORMAT] - monitored_resources = config[CONF_MONITORED_RESOURCES] - resource_list = [ - *FITBIT_RESOURCES_LIST, - SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, - ] - entities = [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - description, - units=description.unit_fn(unit_system), - ) - for description in resource_list - if description.key in monitored_resources - ] - if "devices/battery" in monitored_resources: - devices = asyncio.run_coroutine_threadsafe( - api.async_get_devices(), - hass.loop, - ).result() - entities.extend( - [ - FitbitSensor( - api, - user_profile.encoded_id, - config_path, - FITBIT_RESOURCE_BATTERY, - device, - ) - for device in devices - ] - ) - add_entities(entities, True) - - else: - oauth = FitbitOauth2Client( - config_file.get(CONF_CLIENT_ID), config_file.get(CONF_CLIENT_SECRET) - ) - - redirect_uri = f"{get_url(hass, require_ssl=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - fitbit_auth_start_url, _ = oauth.authorize_token_url( - redirect_uri=redirect_uri, - scope=[ - "activity", - "heartrate", - "nutrition", - "profile", - "settings", - "sleep", - "weight", - ], - ) - - hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url) - hass.http.register_view(FitbitAuthCallbackView(config, add_entities, oauth)) - - request_oauth_completion(hass) - - -class FitbitAuthCallbackView(HomeAssistantView): - """Handle OAuth finish callback requests.""" - - requires_auth = False - url = FITBIT_AUTH_CALLBACK_PATH - name = "api:fitbit:callback" - - def __init__( - self, - config: ConfigType, - add_entities: AddEntitiesCallback, - oauth: FitbitOauth2Client, - ) -> None: - """Initialize the OAuth callback view.""" - self.config = config - self.add_entities = add_entities - self.oauth = oauth - - async def get(self, request: Request) -> str: - """Finish OAuth callback request.""" - hass: HomeAssistant = request.app["hass"] - data = request.query - - response_message = """Fitbit has been successfully authorized! - You can close this window now!""" - - result = None - if data.get("code") is not None: - redirect_uri = f"{get_url(hass, require_current_request=True)}{FITBIT_AUTH_CALLBACK_PATH}" - - try: - result = await hass.async_add_executor_job( - self.oauth.fetch_access_token, data.get("code"), redirect_uri + for description in resource_list + if is_allowed_resource(description) + ] + if is_allowed_resource(FITBIT_RESOURCE_BATTERY): + devices = await api.async_get_devices() + entities.extend( + [ + FitbitSensor( + api, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY, + device=device, + enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), ) - except MissingTokenError as error: - _LOGGER.error("Missing token: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" - except MismatchingStateError as error: - _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = f"""Something went wrong when - attempting authenticating with Fitbit. The error - encountered was {error}. Please try again!""" - else: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ - - if result is None: - _LOGGER.error("Unknown error when authing") - response_message = """Something went wrong when - attempting authenticating with Fitbit. - An unknown error occurred. Please try again! - """ - - html_response = f"""Fitbit Auth -

{response_message}

""" - - if result: - config_contents = { - ATTR_ACCESS_TOKEN: result.get("access_token"), - ATTR_REFRESH_TOKEN: result.get("refresh_token"), - CONF_CLIENT_ID: self.oauth.client_id, - CONF_CLIENT_SECRET: self.oauth.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), - } - save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents) - - hass.async_add_job(setup_platform, hass, self.config, self.add_entities) - - return html_response + for device in devices + ] + ) + async_add_entities(entities, True) class FitbitSensor(SensorEntity): @@ -674,15 +599,14 @@ class FitbitSensor(SensorEntity): self, api: FitbitApi, user_profile_id: str, - config_path: str, description: FitbitSensorEntityDescription, device: FitbitDevice | None = None, units: str | None = None, + enable_default_override: bool = False, ) -> None: """Initialize the Fitbit sensor.""" self.entity_description = description self.api = api - self.config_path = config_path self.device = device self._attr_unique_id = f"{user_profile_id}_{description.key}" @@ -693,6 +617,9 @@ class FitbitSensor(SensorEntity): if units is not None: self._attr_native_unit_of_measurement = units + if enable_default_override: + self._attr_entity_registry_enabled_default = True + @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" @@ -730,16 +657,3 @@ class FitbitSensor(SensorEntity): else: result = await self.api.async_get_latest_time_series(resource_type) self._attr_native_value = self.entity_description.value_fn(result) - - self.hass.async_add_executor_job(self._update_token) - - def _update_token(self) -> None: - token = self.api.client.client.session.token - config_contents = { - ATTR_ACCESS_TOKEN: token.get("access_token"), - ATTR_REFRESH_TOKEN: token.get("refresh_token"), - CONF_CLIENT_ID: self.api.client.client.client_id, - CONF_CLIENT_SECRET: self.api.client.client.client_secret, - ATTR_LAST_SAVED_AT: int(time.time()), - } - save_json(self.config_path, config_contents) diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json new file mode 100644 index 00000000000..240f34154ae --- /dev/null +++ b/homeassistant/components/fitbit/strings.json @@ -0,0 +1,38 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "auth": { + "title": "Link Fitbit" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, + "issues": { + "deprecated_yaml_no_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf if it exists and restart Home Assistant and [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + }, + "deprecated_yaml_import": { + "title": "Fitbit YAML configuration is being removed", + "description": "Configuring Fitbit using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically, including OAuth Application Credentials.\n\nRemove the `fitbit` configuration from your configuration.yaml file and remove fitbit.conf and restart Home Assistant to fix this issue." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The Fitbit YAML configuration import failed", + "description": "Configuring Fitbit using YAML is being removed but there was a connection error importing your YAML configuration.\n\nRestart Home Assistant to try again or remove the Fitbit YAML configuration from your configuration.yaml file and remove the fitbit.conf and continue to [set up the integration](/config/integrations/dashboard/add?domain=fitbit) manually." + } + } +} diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 8c9e3a57ddc..a4db1b4c0de 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -5,6 +5,7 @@ To update, run python3 -m script.hassfest APPLICATION_CREDENTIALS = [ "electric_kiwi", + "fitbit", "geocaching", "google", "google_assistant_sdk", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef22ac4f653..b9e1fcf5259 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -143,6 +143,7 @@ FLOWS = { "fibaro", "filesize", "fireservicerota", + "fitbit", "fivem", "fjaraskupan", "flick_electric", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1d9c2208ad0..253669edf7d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1733,7 +1733,7 @@ "fitbit": { "name": "Fitbit", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "fivem": { diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 7499a060933..155e5499543 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -10,15 +10,28 @@ from unittest.mock import patch import pytest from requests_mock.mocker import Mocker -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + DOMAIN, + OAUTH_SCOPES, +) +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry + CLIENT_ID = "1234" CLIENT_SECRET = "5678" PROFILE_USER_ID = "fitbit-api-user-id-1" -FAKE_TOKEN = "some-token" +FAKE_ACCESS_TOKEN = "some-access-token" FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" PROFILE_API_URL = "https://api.fitbit.com/1/user/-/profile.json" DEVICES_API_URL = "https://api.fitbit.com/1/user/-/devices.json" @@ -26,6 +39,14 @@ TIMESERIES_API_URL_FORMAT = ( "https://api.fitbit.com/1/user/-/{resource}/date/today/7d.json" ) +# These constants differ from values in the config entry or fitbit.conf +SERVER_ACCESS_TOKEN = { + "refresh_token": "server-access-token", + "access_token": "server-refresh-token", + "type": "Bearer", + "expires_in": 60, +} + @pytest.fixture(name="token_expiration_time") def mcok_token_expiration_time() -> float: @@ -33,29 +54,73 @@ def mcok_token_expiration_time() -> float: return time.time() + 86400 +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture for expiration time of the config entry auth token.""" + return OAUTH_SCOPES + + +@pytest.fixture(name="token_entry") +def mock_token_entry(token_expiration_time: float, scopes: list[str]) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(scopes), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": FAKE_AUTH_IMPL, + "token": token_entry, + }, + unique_id=PROFILE_USER_ID, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + FAKE_AUTH_IMPL, + ) + + @pytest.fixture(name="fitbit_config_yaml") -def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any]: +def mock_fitbit_config_yaml(token_expiration_time: float) -> dict[str, Any] | None: """Fixture for the yaml fitbit.conf file contents.""" return { - "access_token": FAKE_TOKEN, + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "access_token": FAKE_ACCESS_TOKEN, "refresh_token": FAKE_REFRESH_TOKEN, "last_saved_at": token_expiration_time, } -@pytest.fixture(name="fitbit_config_setup", autouse=True) +@pytest.fixture(name="fitbit_config_setup") def mock_fitbit_config_setup( - fitbit_config_yaml: dict[str, Any], + fitbit_config_yaml: dict[str, Any] | None, ) -> Generator[None, None, None]: """Fixture to mock out fitbit.conf file data loading and persistence.""" - + has_config = fitbit_config_yaml is not None with patch( - "homeassistant.components.fitbit.sensor.os.path.isfile", return_value=True + "homeassistant.components.fitbit.sensor.os.path.isfile", + return_value=has_config, ), patch( "homeassistant.components.fitbit.sensor.load_json_object", return_value=fitbit_config_yaml, - ), patch( - "homeassistant.components.fitbit.sensor.save_json", ): yield @@ -112,6 +177,30 @@ async def mock_sensor_platform_setup( return run +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run + + @pytest.fixture(name="profile_id") def mock_profile_id() -> str: """Fixture for the profile id returned from the API response.""" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py new file mode 100644 index 00000000000..0418f7da0f4 --- /dev/null +++ b/tests/components/fitbit/test_config_flow.py @@ -0,0 +1,315 @@ +"""Test the fitbit config flow.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus +from unittest.mock import patch + +from requests_mock.mocker import Mocker + +from homeassistant import config_entries +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir + +from .conftest import ( + CLIENT_ID, + FAKE_ACCESS_TOKEN, + FAKE_AUTH_IMPL, + FAKE_REFRESH_TOKEN, + PROFILE_API_URL, + PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +REDIRECT_URL = "https://example.com/auth/external/callback" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + assert "token" in data + del data["token"]["expires_at"] + assert dict(config_entry.data) == { + "auth_implementation": FAKE_AUTH_IMPL, + "token": SERVER_ACCESS_TOKEN, + } + + +async def test_api_failure( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, +) -> None: + """Test a failure to fetch the profile during the setup flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + requests_mock.register_uri( + "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "cannot_connect" + + +async def test_config_entry_already_exists( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + requests_mock: Mocker, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, +) -> None: + """Test that an account may only be configured once.""" + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_fitbit_config( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test that platform configuration is imported successfully.""" + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 1 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify valid profile can be fetched from the API + config_entry = entries[0] + assert config_entry.title == "My name" + assert config_entry.unique_id == PROFILE_USER_ID + + data = dict(config_entry.data) + assert "token" in data + del data["token"]["expires_at"] + # Verify imported values from fitbit.conf and configuration.yaml + assert dict(config_entry.data) == { + "auth_implementation": DOMAIN, + "clock_format": "24H", + "monitored_resources": ["activities/steps"], + "token": { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + }, + "unit_system": "default", + } + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_import_fitbit_config_failure_cannot_connect( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, +) -> None: + """Test platform configuration fails to import successfully.""" + + requests_mock.register_uri( + "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + +async def test_import_fitbit_config_already_exists( + hass: HomeAssistant, + config_entry: MockConfigEntry, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test that platform configuration is not imported if it already exists.""" + + # Verify existing config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_config_entry_setup: + await integration_setup() + + assert len(mock_config_entry_setup.mock_calls) == 1 + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_import_setup: + await sensor_platform_setup() + + assert len(mock_import_setup.mock_calls) == 0 + + # Still one config entry + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Verify an issue is raised for deprecated configuration.yaml + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import" + + +async def test_platform_setup_without_import( + hass: HomeAssistant, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, +) -> None: + """Test platform configuration.yaml but no existing fitbit.conf credentials.""" + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + # Verify no configuration entry is imported since the integration is not + # fully setup properly + assert len(mock_setup.mock_calls) == 0 + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 0 + + # Verify an issue is raised for deprecated configuration.yaml + assert len(issue_registry.issues) == 1 + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_no_import" diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py new file mode 100644 index 00000000000..65a7587f736 --- /dev/null +++ b/tests/components/fitbit/test_init.py @@ -0,0 +1,96 @@ +"""Test fitbit component.""" + +from collections.abc import Awaitable, Callable +from http import HTTPStatus + +import pytest + +from homeassistant.components.fitbit.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + OAUTH2_TOKEN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + FAKE_ACCESS_TOKEN, + FAKE_REFRESH_TOKEN, + SERVER_ACCESS_TOKEN, +) + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test setting up the integration.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_refresh_failure( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt fails and will be retried.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_refresh_success( + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt succeeds.""" + + assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN + + aioclient_mock.post( + OAUTH2_TOKEN, + json=SERVER_ACCESS_TOKEN, + ) + + assert await integration_setup() + assert config_entry.state == ConfigEntryState.LOADED + + # Verify token request + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + CONF_CLIENT_ID: CLIENT_ID, + CONF_CLIENT_SECRET: CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": FAKE_REFRESH_TOKEN, + } + + # Verify updated token + assert ( + config_entry.data["token"]["access_token"] + == SERVER_ACCESS_TOKEN["access_token"] + ) diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 636afeacf16..9e2089b959c 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -7,6 +7,8 @@ from typing import Any import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -32,6 +34,12 @@ DEVICE_RESPONSE_ARIA_AIR = { } +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.SENSOR] + + @pytest.mark.parametrize( ( "monitored_resources", @@ -176,6 +184,7 @@ DEVICE_RESPONSE_ARIA_AIR = { ) async def test_sensors( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], entity_registry: er.EntityRegistry, @@ -190,6 +199,8 @@ async def test_sensors( api_resource, timeseries_response(api_resource.replace("/", "-"), api_value) ) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get(entity_id) assert state @@ -204,12 +215,15 @@ async def test_sensors( ) async def test_device_battery_level( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], entity_registry: er.EntityRegistry, ) -> None: """Test battery level sensor for devices.""" - await sensor_platform_setup() + assert await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.charge_2_battery") assert state @@ -269,6 +283,7 @@ async def test_device_battery_level( ) async def test_profile_local( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], expected_unit: str, @@ -277,6 +292,8 @@ async def test_profile_local( register_timeseries("body/weight", timeseries_response("body-weight", "175")) await sensor_platform_setup() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 state = hass.states.get("sensor.weight") assert state @@ -315,6 +332,7 @@ async def test_profile_local( ) async def test_sleep_time_clock_format( hass: HomeAssistant, + fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], api_response: str, @@ -330,3 +348,165 @@ async def test_sleep_time_clock_format( state = hass.states.get("sensor.sleep_start_time") assert state assert state.state == expected_state + + +@pytest.mark.parametrize( + ("scopes"), + [(["activity"])], +) +async def test_activity_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test activity sensors are enabled.""" + + for api_resource in ( + "activities/activityCalories", + "activities/calories", + "activities/distance", + "activities/elevation", + "activities/floors", + "activities/minutesFairlyActive", + "activities/minutesLightlyActive", + "activities/minutesSedentary", + "activities/minutesVeryActive", + "activities/steps", + ): + register_timeseries( + api_resource, timeseries_response(api_resource.replace("/", "-"), "0") + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.activity_calories", + "sensor.calories", + "sensor.distance", + "sensor.elevation", + "sensor.floors", + "sensor.minutes_fairly_active", + "sensor.minutes_lightly_active", + "sensor.minutes_sedentary", + "sensor.minutes_very_active", + "sensor.steps", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_heartrate_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test heartrate sensors are enabled.""" + + register_timeseries( + "activities/heart", + timeseries_response("activities-heart", {"restingHeartRate": "0"}), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.resting_heart_rate", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["sleep"])], +) +async def test_sleep_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test sleep sensors are enabled.""" + + for api_resource in ( + "sleep/startTime", + "sleep/timeInBed", + "sleep/minutesToFallAsleep", + "sleep/minutesAwake", + "sleep/minutesAsleep", + "sleep/minutesAfterWakeup", + "sleep/efficiency", + "sleep/awakeningsCount", + ): + register_timeseries( + api_resource, + timeseries_response(api_resource.replace("/", "-"), "0"), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert {s.entity_id for s in states} == { + "sensor.awakenings_count", + "sensor.sleep_efficiency", + "sensor.minutes_after_wakeup", + "sensor.sleep_minutes_asleep", + "sensor.sleep_minutes_awake", + "sensor.sleep_minutes_to_fall_asleep", + "sensor.sleep_time_in_bed", + "sensor.sleep_start_time", + } + + +@pytest.mark.parametrize( + ("scopes"), + [(["weight"])], +) +async def test_weight_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test sleep sensors are enabled.""" + + register_timeseries("body/weight", timeseries_response("body-weight", "0")) + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.weight", + ] + + +@pytest.mark.parametrize( + ("scopes", "devices_response"), + [(["settings"], [DEVICE_RESPONSE_CHARGE_2])], +) +async def test_settings_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + entity_registry: er.EntityRegistry, +) -> None: + """Test heartrate sensors are enabled.""" + + for api_resource in ("activities/heart",): + register_timeseries( + api_resource, + timeseries_response( + api_resource.replace("/", "-"), {"restingHeartRate": "0"} + ), + ) + assert await integration_setup() + + states = hass.states.async_all() + assert [s.entity_id for s in states] == [ + "sensor.charge_2_battery", + ] From b4555c8a9246c52df704aa8ecafec8859d66a126 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 1 Oct 2023 02:28:14 -0400 Subject: [PATCH 075/968] Bump zwave-js-server-python to 0.52.1 (#101162) --- homeassistant/components/zwave_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/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 3e8a5e4f757..505196c43eb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index ba9ae32e797..bba1071001b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2817,7 +2817,7 @@ zigpy==0.57.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c775b54b0cc..0a6fc57ca0c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2102,7 +2102,7 @@ zigpy-znp==0.11.5 zigpy==0.57.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.0 +zwave-js-server-python==0.52.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 9b754a58f4ed07babad1c7091792cfb14d56d6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sun, 1 Oct 2023 10:12:06 +0200 Subject: [PATCH 076/968] Update Mill library to 0.11.6 (#101180) --- homeassistant/components/mill/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json index 561a24c29df..cb0ba4522bf 100644 --- a/homeassistant/components/mill/manifest.json +++ b/homeassistant/components/mill/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mill", "iot_class": "local_polling", "loggers": ["mill", "mill_local"], - "requirements": ["millheater==0.11.5", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.6", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index bba1071001b..34c38e84a02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1222,7 +1222,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a6fc57ca0c..317216fd341 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -948,7 +948,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.5 +millheater==0.11.6 # homeassistant.components.minio minio==7.1.12 From fd1f0b0efeb5231d3ee23d1cb2a10cdeff7c23f1 Mon Sep 17 00:00:00 2001 From: Oliver <10700296+ol-iver@users.noreply.github.com> Date: Sun, 1 Oct 2023 12:26:28 +0200 Subject: [PATCH 077/968] Update denonavr to `0.11.4` (#101169) --- .../components/denonavr/manifest.json | 2 +- .../components/denonavr/media_player.py | 35 ++++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 34 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index b3c36ed39d2..0ba8caed6c5 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/denonavr", "iot_class": "local_push", "loggers": ["denonavr"], - "requirements": ["denonavr==0.11.3"], + "requirements": ["denonavr==0.11.4"], "ssdp": [ { "manufacturer": "Denon", diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 51ede0d65b4..8b6907a60f7 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -8,7 +8,15 @@ import logging from typing import Any, Concatenate, ParamSpec, TypeVar from denonavr import DenonAVR -from denonavr.const import POWER_ON, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING +from denonavr.const import ( + ALL_TELNET_EVENTS, + ALL_ZONES, + POWER_ON, + STATE_OFF, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +) from denonavr.exceptions import ( AvrCommandError, AvrForbiddenError, @@ -73,6 +81,23 @@ SERVICE_GET_COMMAND = "get_command" SERVICE_SET_DYNAMIC_EQ = "set_dynamic_eq" SERVICE_UPDATE_AUDYSSEY = "update_audyssey" +# HA Telnet events +TELNET_EVENTS = { + "HD", + "MS", + "MU", + "MV", + "NS", + "NSE", + "PS", + "SI", + "SS", + "TF", + "ZM", + "Z2", + "Z3", +} + _DenonDeviceT = TypeVar("_DenonDeviceT", bound="DenonDevice") _R = TypeVar("_R") _P = ParamSpec("_P") @@ -254,7 +279,9 @@ class DenonDevice(MediaPlayerEntity): async def _telnet_callback(self, zone, event, parameter) -> None: """Process a telnet command callback.""" # There are multiple checks implemented which reduce unnecessary updates of the ha state machine - if zone != self._receiver.zone: + if zone not in (self._receiver.zone, ALL_ZONES): + return + if event not in TELNET_EVENTS: return # Some updates trigger multiple events like one for artist and one for title for one change # We skip every event except the last one @@ -268,11 +295,11 @@ class DenonDevice(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Register for telnet events.""" - self._receiver.register_callback("ALL", self._telnet_callback) + self._receiver.register_callback(ALL_TELNET_EVENTS, self._telnet_callback) async def async_will_remove_from_hass(self) -> None: """Clean up the entity.""" - self._receiver.unregister_callback("ALL", self._telnet_callback) + self._receiver.unregister_callback(ALL_TELNET_EVENTS, self._telnet_callback) @async_log_errors async def async_update(self) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 34c38e84a02..1ee07117d1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -668,7 +668,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 317216fd341..95ec8b1a9a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -545,7 +545,7 @@ deluge-client==1.7.1 demetriek==0.4.0 # homeassistant.components.denonavr -denonavr==0.11.3 +denonavr==0.11.4 # homeassistant.components.devolo_home_control devolo-home-control-api==0.18.2 From 9306e605302a5dcc139d6f4c21c9d1a92d0e1291 Mon Sep 17 00:00:00 2001 From: hlyi Date: Sun, 1 Oct 2023 06:21:26 -0500 Subject: [PATCH 078/968] Report unavailability for yolink sensor and binary_sensor (#100743) --- homeassistant/components/yolink/binary_sensor.py | 5 +++++ homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/coordinator.py | 12 ++++++++++-- homeassistant/components/yolink/sensor.py | 5 +++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 38ea7d46537..e65896cdd42 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -136,3 +136,8 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): state.get(self.entity_description.state_key) ) self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 935889a0368..9fc4dac8ada 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -8,3 +8,4 @@ ATTR_DEVICE_NAME = "name" ATTR_DEVICE_STATE = "state" ATTR_DEVICE_ID = "deviceId" YOLINK_EVENT = f"{DOMAIN}_event" +YOLINK_OFFLINE_TIME = 32400 diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index 9055b2d044e..f2c942caab9 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from datetime import timedelta +from datetime import UTC, datetime, timedelta import logging from yolink.device import YoLinkDevice @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN, YOLINK_OFFLINE_TIME _LOGGER = logging.getLogger(__name__) @@ -37,6 +37,7 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): ) self.device = device self.paired_device = paired_device + self.dev_online = True async def _async_update_data(self) -> dict: """Fetch device state.""" @@ -44,6 +45,13 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): async with asyncio.timeout(10): device_state_resp = await self.device.fetch_state() device_state = device_state_resp.data.get(ATTR_DEVICE_STATE) + device_reporttime = device_state_resp.data.get("reportAt") + if device_reporttime is not None: + rpt_time_delta = ( + datetime.now(tz=UTC).replace(tzinfo=None) + - datetime.strptime(device_reporttime, "%Y-%m-%dT%H:%M:%S.%fZ") + ).total_seconds() + self.dev_online = rpt_time_delta < YOLINK_OFFLINE_TIME if self.paired_device is not None and device_state is not None: paried_device_state_resp = await self.paired_device.fetch_state() paried_device_state = paried_device_state_resp.data.get( diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 451b486acd2..2fc4a2b0725 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -261,3 +261,8 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): return self._attr_native_value = attr_val self.async_write_ha_state() + + @property + def available(self) -> bool: + """Return true is device is available.""" + return super().available and self.coordinator.dev_online From 5e6735ab6d315881de6bdf3df1fd35613cd4e294 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 1 Oct 2023 15:05:10 +0200 Subject: [PATCH 079/968] Update home-assistant/wheels to 2023.10.1 (#101197) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a2a1fc924fd..2882f855a0d 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.09.3 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -180,7 +180,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.09.3 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -194,7 +194,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.09.3 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -208,7 +208,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.09.3 + uses: home-assistant/wheels@2023.10.1 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 87ecdfb84f97ee36fb3710588be4b66910b954b7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Oct 2023 06:25:04 -0700 Subject: [PATCH 080/968] Clear calendar alarms after scheduling and add debug loggging (#101176) --- homeassistant/components/calendar/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 96872e039e1..1622f568a2d 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -531,6 +531,7 @@ class CalendarEntity(Entity): for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() now = dt_util.now() event = self.event @@ -540,6 +541,7 @@ class CalendarEntity(Entity): @callback def update(_: datetime.datetime) -> None: """Run when the active or upcoming event starts or ends.""" + _LOGGER.debug("Running %s update", self.entity_id) self._async_write_ha_state() if now < event.start_datetime_local: @@ -553,6 +555,13 @@ class CalendarEntity(Entity): self._alarm_unsubs.append( async_track_point_in_time(self.hass, update, event.end_datetime_local) ) + _LOGGER.debug( + "Scheduled %d updates for %s (%s, %s)", + len(self._alarm_unsubs), + self.entity_id, + event.start_datetime_local, + event.end_datetime_local, + ) async def async_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -561,6 +570,7 @@ class CalendarEntity(Entity): """ for unsub in self._alarm_unsubs: unsub() + self._alarm_unsubs.clear() async def async_get_events( self, From a4a99ce957397ae6f6181da43e191275de229ae1 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 1 Oct 2023 15:06:14 +0100 Subject: [PATCH 081/968] Remove deprecated volume conversion functions (#101200) --- docs/source/api/util.rst | 8 -- homeassistant/util/volume.py | 52 ----------- tests/util/test_unit_conversion.py | 2 +- tests/util/test_volume.py | 138 ----------------------------- 4 files changed, 1 insertion(+), 199 deletions(-) delete mode 100644 homeassistant/util/volume.py delete mode 100644 tests/util/test_volume.py diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 071f4d81cdf..4c74417e4d4 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -141,11 +141,3 @@ homeassistant.util.unit\_system :members: :undoc-members: :show-inheritance: - -homeassistant.util.volume -------------------------- - -.. automodule:: homeassistant.util.volume - :members: - :undoc-members: - :show-inheritance: diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py deleted file mode 100644 index 8aae8ff104e..00000000000 --- a/homeassistant/util/volume.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Volume conversion util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - UNIT_NOT_RECOGNIZED_TEMPLATE, - VOLUME, - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import VolumeConverter - -VALID_UNITS = VolumeConverter.VALID_UNITS - - -def liter_to_gallon(liter: float) -> float: - """Convert a volume measurement in Liter to Gallon.""" - return convert(liter, VOLUME_LITERS, VOLUME_GALLONS) - - -def gallon_to_liter(gallon: float) -> float: - """Convert a volume measurement in Gallon to Liter.""" - return convert(gallon, VOLUME_GALLONS, VOLUME_LITERS) - - -def cubic_meter_to_cubic_feet(cubic_meter: float) -> float: - """Convert a volume measurement in cubic meter to cubic feet.""" - return convert(cubic_meter, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET) - - -def cubic_feet_to_cubic_meter(cubic_feet: float) -> float: - """Convert a volume measurement in cubic feet to cubic meter.""" - return convert(cubic_feet, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS) - - -def convert(volume: float, from_unit: str, to_unit: str) -> float: - """Convert a volume from one unit to another.""" - report( - ( - "uses volume utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.VolumeConverter instead" - ), - error_if_core=False, - ) - return VolumeConverter.convert(volume, from_unit, to_unit) diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 18f0c9a12c1..e7affecfaf4 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -105,7 +105,7 @@ _GET_UNIT_RATIO: dict[type[BaseUnitConverter], tuple[str | None, str | None, flo VolumeConverter: (UnitOfVolume.GALLONS, UnitOfVolume.LITERS, 0.264172), } -# Dict containing a conversion test for every know unit. +# Dict containing a conversion test for every known unit. _CONVERTED_VALUE: dict[ type[BaseUnitConverter], list[tuple[float, str | None, float, str | None]] ] = { diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py deleted file mode 100644 index f8a73929b70..00000000000 --- a/tests/util/test_volume.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Test Home Assistant volume utility functions.""" - -import pytest - -from homeassistant.const import ( - VOLUME_CUBIC_FEET, - VOLUME_CUBIC_METERS, - VOLUME_FLUID_OUNCE, - VOLUME_GALLONS, - VOLUME_LITERS, - VOLUME_MILLILITERS, -) -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.volume as volume_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = VOLUME_LITERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert "use unit_conversion.VolumeConverter instead" in caplog.text - - -@pytest.mark.parametrize( - ("function_name", "value", "expected"), - [ - ("liter_to_gallon", 2, pytest.approx(0.528344)), - ("gallon_to_liter", 2, 7.570823568), - ("cubic_meter_to_cubic_feet", 2, pytest.approx(70.629333)), - ("cubic_feet_to_cubic_meter", 2, pytest.approx(0.0566337)), - ], -) -def test_deprecated_functions( - function_name: str, value: float, expected: float -) -> None: - """Test that deprecated function still work.""" - convert = getattr(volume_util, function_name) - assert convert(value) == expected - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2 - assert volume_util.convert(3, VOLUME_MILLILITERS, VOLUME_MILLILITERS) == 3 - assert volume_util.convert(4, VOLUME_GALLONS, VOLUME_GALLONS) == 4 - assert volume_util.convert(5, VOLUME_FLUID_OUNCE, VOLUME_FLUID_OUNCE) == 5 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - volume_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - volume_util.convert("a", VOLUME_GALLONS, VOLUME_LITERS) - - -def test_convert_from_liters() -> None: - """Test conversion from liters to other units.""" - liters = 5 - assert volume_util.convert(liters, VOLUME_LITERS, VOLUME_GALLONS) == pytest.approx( - 1.32086 - ) - - -def test_convert_from_gallons() -> None: - """Test conversion from gallons to other units.""" - gallons = 5 - assert volume_util.convert(gallons, VOLUME_GALLONS, VOLUME_LITERS) == pytest.approx( - 18.92706 - ) - - -def test_convert_from_cubic_meters() -> None: - """Test conversion from cubic meter to other units.""" - cubic_meters = 5 - assert volume_util.convert( - cubic_meters, VOLUME_CUBIC_METERS, VOLUME_CUBIC_FEET - ) == pytest.approx(176.5733335) - - -def test_convert_from_cubic_feet() -> None: - """Test conversion from cubic feet to cubic meters to other units.""" - cubic_feets = 500 - assert volume_util.convert( - cubic_feets, VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS - ) == pytest.approx(14.1584233) - - -@pytest.mark.parametrize( - ("source_unit", "target_unit", "expected"), - [ - (VOLUME_CUBIC_FEET, VOLUME_CUBIC_METERS, 14.1584233), - (VOLUME_CUBIC_FEET, VOLUME_FLUID_OUNCE, 478753.2467), - (VOLUME_CUBIC_FEET, VOLUME_GALLONS, 3740.25974), - (VOLUME_CUBIC_FEET, VOLUME_LITERS, 14158.42329599), - (VOLUME_CUBIC_FEET, VOLUME_MILLILITERS, 14158423.29599), - (VOLUME_CUBIC_METERS, VOLUME_CUBIC_METERS, 500), - (VOLUME_CUBIC_METERS, VOLUME_FLUID_OUNCE, 16907011.35), - (VOLUME_CUBIC_METERS, VOLUME_GALLONS, 132086.02617), - (VOLUME_CUBIC_METERS, VOLUME_LITERS, 500000), - (VOLUME_CUBIC_METERS, VOLUME_MILLILITERS, 500000000), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_FEET, 0.52218967), - (VOLUME_FLUID_OUNCE, VOLUME_CUBIC_METERS, 0.014786764), - (VOLUME_FLUID_OUNCE, VOLUME_GALLONS, 3.90625), - (VOLUME_FLUID_OUNCE, VOLUME_LITERS, 14.786764), - (VOLUME_FLUID_OUNCE, VOLUME_MILLILITERS, 14786.764), - (VOLUME_GALLONS, VOLUME_CUBIC_FEET, 66.84027), - (VOLUME_GALLONS, VOLUME_CUBIC_METERS, 1.892706), - (VOLUME_GALLONS, VOLUME_FLUID_OUNCE, 64000), - (VOLUME_GALLONS, VOLUME_LITERS, 1892.70589), - (VOLUME_GALLONS, VOLUME_MILLILITERS, 1892705.89), - (VOLUME_LITERS, VOLUME_CUBIC_FEET, 17.65733), - (VOLUME_LITERS, VOLUME_CUBIC_METERS, 0.5), - (VOLUME_LITERS, VOLUME_FLUID_OUNCE, 16907.011), - (VOLUME_LITERS, VOLUME_GALLONS, 132.086), - (VOLUME_LITERS, VOLUME_MILLILITERS, 500000), - (VOLUME_MILLILITERS, VOLUME_CUBIC_FEET, 0.01765733), - (VOLUME_MILLILITERS, VOLUME_CUBIC_METERS, 0.0005), - (VOLUME_MILLILITERS, VOLUME_FLUID_OUNCE, 16.907), - (VOLUME_MILLILITERS, VOLUME_GALLONS, 0.132086), - (VOLUME_MILLILITERS, VOLUME_LITERS, 0.5), - ], -) -def test_convert(source_unit, target_unit, expected) -> None: - """Test conversion between units.""" - value = 500 - assert volume_util.convert(value, source_unit, target_unit) == pytest.approx( - expected - ) From 31ea00f5c7a9639a8946364242da57d59584b5bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 1 Oct 2023 17:16:19 +0300 Subject: [PATCH 082/968] Treat strings starting with https but not htt as soundtouch media URLs (#101183) --- homeassistant/components/soundtouch/media_player.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index fa5c0dd7095..831b64f7056 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations from functools import partial import logging -import re from typing import Any from libsoundtouch.device import SoundTouchDevice @@ -250,7 +249,7 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): ) -> None: """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) - if re.match(r"http?://", str(media_id)): + if str(media_id).lower().startswith("http://"): # no https support # URL _LOGGER.debug("Playing URL %s", str(media_id)) self._device.play_url(str(media_id)) From 8b7fae52003947d25835cdc0aafed55640f5d450 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 1 Oct 2023 15:17:31 +0100 Subject: [PATCH 083/968] Remove deprecated distance conversion functions (#101199) --- docs/source/api/util.rst | 8 -- homeassistant/util/distance.py | 58 --------- tests/util/test_distance.py | 211 --------------------------------- 3 files changed, 277 deletions(-) delete mode 100644 homeassistant/util/distance.py delete mode 100644 tests/util/test_distance.py diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 4c74417e4d4..f670fc52204 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -46,14 +46,6 @@ homeassistant.util.decorator :undoc-members: :show-inheritance: -homeassistant.util.distance ---------------------------- - -.. automodule:: homeassistant.util.distance - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.dt --------------------- diff --git a/homeassistant/util/distance.py b/homeassistant/util/distance.py deleted file mode 100644 index 45b105aea9f..00000000000 --- a/homeassistant/util/distance.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Distance util functions.""" -from __future__ import annotations - -from collections.abc import Callable - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - LENGTH, - LENGTH_CENTIMETERS, - LENGTH_FEET, - LENGTH_INCHES, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - LENGTH_MILLIMETERS, - LENGTH_YARD, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import DistanceConverter - -VALID_UNITS = DistanceConverter.VALID_UNITS - -TO_METERS: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda miles: miles * 1609.344, - LENGTH_YARD: lambda yards: yards * 0.9144, - LENGTH_FEET: lambda feet: feet * 0.3048, - LENGTH_INCHES: lambda inches: inches * 0.0254, - LENGTH_KILOMETERS: lambda kilometers: kilometers * 1000, - LENGTH_CENTIMETERS: lambda centimeters: centimeters * 0.01, - LENGTH_MILLIMETERS: lambda millimeters: millimeters * 0.001, -} - -METERS_TO: dict[str, Callable[[float], float]] = { - LENGTH_METERS: lambda meters: meters, - LENGTH_MILES: lambda meters: meters * 0.000621371, - LENGTH_YARD: lambda meters: meters * 1.09361, - LENGTH_FEET: lambda meters: meters * 3.28084, - LENGTH_INCHES: lambda meters: meters * 39.3701, - LENGTH_KILOMETERS: lambda meters: meters * 0.001, - LENGTH_CENTIMETERS: lambda meters: meters * 100, - LENGTH_MILLIMETERS: lambda meters: meters * 1000, -} - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses distance utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.DistanceConverter instead" - ), - error_if_core=False, - ) - return DistanceConverter.convert(value, from_unit, to_unit) diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py deleted file mode 100644 index c6a9d59cb73..00000000000 --- a/tests/util/test_distance.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Test Home Assistant distance utility functions.""" - -import pytest - -from homeassistant.const import UnitOfLength -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.distance as distance_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfLength.KILOMETERS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert "use unit_conversion.DistanceConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert ( - distance_util.convert(5, UnitOfLength.KILOMETERS, UnitOfLength.KILOMETERS) == 5 - ) - assert distance_util.convert(2, UnitOfLength.METERS, UnitOfLength.METERS) == 2 - assert ( - distance_util.convert(6, UnitOfLength.CENTIMETERS, UnitOfLength.CENTIMETERS) - == 6 - ) - assert ( - distance_util.convert(3, UnitOfLength.MILLIMETERS, UnitOfLength.MILLIMETERS) - == 3 - ) - assert distance_util.convert(10, UnitOfLength.MILES, UnitOfLength.MILES) == 10 - assert distance_util.convert(9, UnitOfLength.YARDS, UnitOfLength.YARDS) == 9 - assert distance_util.convert(8, UnitOfLength.FEET, UnitOfLength.FEET) == 8 - assert distance_util.convert(7, UnitOfLength.INCHES, UnitOfLength.INCHES) == 7 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - distance_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - distance_util.convert("a", UnitOfLength.KILOMETERS, UnitOfLength.METERS) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 8.04672), - (UnitOfLength.METERS, 8046.72), - (UnitOfLength.CENTIMETERS, 804672.0), - (UnitOfLength.MILLIMETERS, 8046720.0), - (UnitOfLength.YARDS, 8800.0), - (UnitOfLength.FEET, 26400.0008448), - (UnitOfLength.INCHES, 316800.171072), - ], -) -def test_convert_from_miles(unit, expected) -> None: - """Test conversion from miles to other units.""" - miles = 5 - assert distance_util.convert(miles, UnitOfLength.MILES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.0045720000000000005), - (UnitOfLength.METERS, 4.572), - (UnitOfLength.CENTIMETERS, 457.2), - (UnitOfLength.MILLIMETERS, 4572), - (UnitOfLength.MILES, 0.002840908212), - (UnitOfLength.FEET, 15.00000048), - (UnitOfLength.INCHES, 180.0000972), - ], -) -def test_convert_from_yards(unit, expected) -> None: - """Test conversion from yards to other units.""" - yards = 5 - assert distance_util.convert(yards, UnitOfLength.YARDS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 1.524), - (UnitOfLength.METERS, 1524), - (UnitOfLength.CENTIMETERS, 152400.0), - (UnitOfLength.MILLIMETERS, 1524000.0), - (UnitOfLength.MILES, 0.9469694040000001), - (UnitOfLength.YARDS, 1666.66667), - (UnitOfLength.INCHES, 60000.032400000004), - ], -) -def test_convert_from_feet(unit, expected) -> None: - """Test conversion from feet to other units.""" - feet = 5000 - assert distance_util.convert(feet, UnitOfLength.FEET, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 0.127), - (UnitOfLength.METERS, 127.0), - (UnitOfLength.CENTIMETERS, 12700.0), - (UnitOfLength.MILLIMETERS, 127000.0), - (UnitOfLength.MILES, 0.078914117), - (UnitOfLength.YARDS, 138.88889), - (UnitOfLength.FEET, 416.66668), - ], -) -def test_convert_from_inches(unit, expected) -> None: - """Test conversion from inches to other units.""" - inches = 5000 - assert distance_util.convert(inches, UnitOfLength.INCHES, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_kilometers(unit, expected) -> None: - """Test conversion from kilometers to other units.""" - km = 5 - assert distance_util.convert(km, UnitOfLength.KILOMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_meters(unit, expected) -> None: - """Test conversion from meters to other units.""" - m = 5000 - assert distance_util.convert(m, UnitOfLength.METERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.MILLIMETERS, 5000000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_centimeters(unit, expected) -> None: - """Test conversion from centimeters to other units.""" - cm = 500000 - assert distance_util.convert(cm, UnitOfLength.CENTIMETERS, unit) == pytest.approx( - expected - ) - - -@pytest.mark.parametrize( - ("unit", "expected"), - [ - (UnitOfLength.KILOMETERS, 5), - (UnitOfLength.METERS, 5000), - (UnitOfLength.CENTIMETERS, 500000), - (UnitOfLength.MILES, 3.106855), - (UnitOfLength.YARDS, 5468.066), - (UnitOfLength.FEET, 16404.2), - (UnitOfLength.INCHES, 196850.5), - ], -) -def test_convert_from_millimeters(unit, expected) -> None: - """Test conversion from millimeters to other units.""" - mm = 5000000 - assert distance_util.convert(mm, UnitOfLength.MILLIMETERS, unit) == pytest.approx( - expected - ) From a3808383d5081e85d3f312e72af373c0766e8175 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 1 Oct 2023 16:18:05 +0200 Subject: [PATCH 084/968] Fix binary sensor test in command_line (#101198) --- tests/components/command_line/test_binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 7d5db4603fe..9787728965e 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -304,7 +304,7 @@ async def test_updating_manually( await hass.async_block_till_done() assert called - called.clear + called.clear() await hass.services.async_call( HA_DOMAIN, From f4bf8fa8f13a4c4525e7b00078ed8a5097f68d4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 1 Oct 2023 17:19:24 +0300 Subject: [PATCH 085/968] Catch HTML case insensitively in "no HTML" config validation (#101181) --- homeassistant/components/owntracks/strings.json | 2 +- homeassistant/helpers/config_validation.py | 2 +- tests/helpers/test_config_validation.py | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json index 2486e01223f..499b598d7ae 100644 --- a/homeassistant/components/owntracks/strings.json +++ b/homeassistant/components/owntracks/strings.json @@ -11,7 +11,7 @@ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { - "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `''`\n - Device ID: `''`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `''`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." + "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to Preferences > Connection. Change the following settings:\n - Mode: HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: `'(Your name)'`\n - Device ID: `'(Your device name)'`\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left > Settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: `'(Your name)'`\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information." } } } diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index a4018101d0e..eed57e7ea25 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -599,7 +599,7 @@ def string(value: Any) -> str: def string_with_no_html(value: Any) -> str: """Validate that the value is a string without HTML.""" value = string(value) - regex = re.compile(r"<[a-z][\s\S]*>") + regex = re.compile(r"<[a-z].*?>", re.IGNORECASE) if regex.search(value): raise vol.Invalid("the string should not contain HTML") return str(value) diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 80fc1bf2241..a9ddd89a0b3 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -563,6 +563,9 @@ def test_string_with_no_html() -> None: with pytest.raises(vol.Invalid): schema("Bold") + with pytest.raises(vol.Invalid): + schema("HTML element names are case-insensitive.") + for value in ( True, 3, From b3b5ca9b9592f2df4ff5e2b740cb5c2b52cd2498 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Sun, 1 Oct 2023 16:20:09 +0200 Subject: [PATCH 086/968] Terminology: Rename Multi-PAN to Multiprotocol to be consistent (#99262) --- .../silabs_multiprotocol_addon.py | 2 +- .../components/homeassistant_sky_connect/__init__.py | 2 +- .../homeassistant_sky_connect/config_flow.py | 2 +- .../components/homeassistant_yellow/__init__.py | 2 +- .../components/homeassistant_yellow/config_flow.py | 2 +- .../test_silabs_multiprotocol_addon.py | 12 ++++++------ .../homeassistant_sky_connect/test_init.py | 2 +- tests/components/homeassistant_yellow/test_init.py | 2 +- 8 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index c04575d8005..40cf1e18b0e 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -885,7 +885,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): async def check_multi_pan_addon(hass: HomeAssistant) -> None: - """Check the multi-PAN addon state, and start it if installed but not started. + """Check the multiprotocol addon state, and start it if installed but not started. Does nothing if Hass.io is not loaded. Raises on error or if the add-on is installed but not started. diff --git a/homeassistant/components/homeassistant_sky_connect/__init__.py b/homeassistant/components/homeassistant_sky_connect/__init__.py index 5f17069f5d5..218e0c3e88d 100644 --- a/homeassistant/components/homeassistant_sky_connect/__init__.py +++ b/homeassistant/components/homeassistant_sky_connect/__init__.py @@ -45,7 +45,7 @@ async def _async_usb_scan_done(hass: HomeAssistant, entry: ConfigEntry) -> None: return hw_discovery_data = { - "name": "SkyConnect Multi-PAN", + "name": "SkyConnect Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 5ac44f3f290..fce731777b1 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -76,7 +76,7 @@ class HomeAssistantSkyConnectOptionsFlow(silabs_multiprotocol_addon.OptionsFlowH def _zha_name(self) -> str: """Return the ZHA name.""" - return "SkyConnect Multi-PAN" + return "SkyConnect Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 30015d1bae4..b61e01061c3 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -35,7 +35,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hw_discovery_data = ZHA_HW_DISCOVERY_DATA else: hw_discovery_data = { - "name": "Yellow Multi-PAN", + "name": "Yellow Multiprotocol", "port": { "path": get_zigbee_socket(), }, diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 8be7b8a4ff7..667b8f3d97a 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -153,7 +153,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl def _zha_name(self) -> str: """Return the ZHA name.""" - return "Yellow Multi-PAN" + return "Yellow Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index 17cd288050c..fbc77cdee9e 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -85,7 +85,7 @@ class FakeOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandler): def _zha_name(self) -> str: """Return the ZHA name.""" - return "Test Multi-PAN" + return "Test Multiprotocol" def _hardware_name(self) -> str: """Return the name of the hardware.""" @@ -353,7 +353,7 @@ async def test_option_flow_install_multi_pan_addon_zha( }, "radio_type": "ezsp", } - assert zha_config_entry.title == "Test Multi-PAN" + assert zha_config_entry.title == "Test Multiprotocol" result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE @@ -663,7 +663,7 @@ async def test_option_flow_addon_installed_same_device_uninstall( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -928,7 +928,7 @@ async def test_option_flow_flasher_install_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1071,7 +1071,7 @@ async def test_option_flow_uninstall_migration_initiate_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) @@ -1132,7 +1132,7 @@ async def test_option_flow_uninstall_migration_finish_failure( }, domain=ZHA_DOMAIN, options={}, - title="Test Multi-PAN", + title="Test Multiprotocol", ) zha_config_entry.add_to_hass(hass) diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index 3afc8c24774..e00603dc8f7 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -207,7 +207,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "SkyConnect Multi-PAN" + assert config_entry.title == "SkyConnect Multiprotocol" async def test_setup_zha_multipan_other_device( diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index a785e46c8b2..addc519c865 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -152,7 +152,7 @@ async def test_setup_zha_multipan( "radio_type": "ezsp", } assert config_entry.options == {} - assert config_entry.title == "Yellow Multi-PAN" + assert config_entry.title == "Yellow Multiprotocol" async def test_setup_zha_multipan_other_device( From 2d58ab0e1c7c606de6ea58b02026a10ed5ea2591 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Oct 2023 08:12:44 -0700 Subject: [PATCH 087/968] Fix rainbird entity unique ids (#101168) * Fix unique ids for rainbird entities * Update entity unique id use based on config entry entity id * Update tests/components/rainbird/test_binary_sensor.py Co-authored-by: Martin Hjelmare * Rename all entity_registry variables * Shorten long comment under line length limits --------- Co-authored-by: Martin Hjelmare --- .../components/rainbird/binary_sensor.py | 7 ++- homeassistant/components/rainbird/calendar.py | 17 ++++--- .../components/rainbird/coordinator.py | 27 +++++++---- homeassistant/components/rainbird/number.py | 7 ++- homeassistant/components/rainbird/sensor.py | 9 +++- homeassistant/components/rainbird/switch.py | 19 ++++---- tests/components/rainbird/conftest.py | 6 +-- .../components/rainbird/test_binary_sensor.py | 36 ++++++++++++++ tests/components/rainbird/test_calendar.py | 30 ++++++++++++ tests/components/rainbird/test_config_flow.py | 4 +- tests/components/rainbird/test_number.py | 34 +++++++++++++- tests/components/rainbird/test_sensor.py | 47 ++++++++++++++++++- tests/components/rainbird/test_switch.py | 32 +++++++++++++ 13 files changed, 237 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index b5886011ea3..3333d8bc4cb 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -48,8 +48,11 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rainsensor" @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 4d8cc38c8bf..356f7d7cc4e 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -34,8 +34,9 @@ async def async_setup_entry( [ RainBirdCalendarEntity( data.schedule_coordinator, - data.coordinator.serial_number, + data.coordinator.unique_id, data.coordinator.device_info, + data.coordinator.device_name, ) ] ) @@ -47,20 +48,24 @@ class RainBirdCalendarEntity( """A calendar event entity.""" _attr_has_entity_name = True - _attr_name = None + _attr_name: str | None = None _attr_icon = "mdi:sprinkler" def __init__( self, coordinator: RainbirdScheduleUpdateCoordinator, - serial_number: str, - device_info: DeviceInfo, + unique_id: str | None, + device_info: DeviceInfo | None, + device_name: str, ) -> None: """Create the Calendar event device.""" super().__init__(coordinator) self._event: CalendarEvent | None = None - self._attr_unique_id = serial_number - self._attr_device_info = device_info + if unique_id: + self._attr_unique_id = unique_id + self._attr_device_info = device_info + else: + self._attr_name = device_name @property def event(self) -> CalendarEvent | None: diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 5c40ef808b2..763e50fe5d9 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER, TIMEOUT_SECONDS +from .const import DOMAIN, MANUFACTURER, TIMEOUT_SECONDS UPDATE_INTERVAL = datetime.timedelta(minutes=1) # The calendar data requires RPCs for each program/zone, and the data rarely @@ -51,7 +51,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): hass: HomeAssistant, name: str, controller: AsyncRainbirdController, - serial_number: str, + unique_id: str | None, model_info: ModelAndVersion, ) -> None: """Initialize RainbirdUpdateCoordinator.""" @@ -62,7 +62,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): update_interval=UPDATE_INTERVAL, ) self._controller = controller - self._serial_number = serial_number + self._unique_id = unique_id self._zones: set[int] | None = None self._model_info = model_info @@ -72,16 +72,23 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): return self._controller @property - def serial_number(self) -> str: - """Return the device serial number.""" - return self._serial_number + def unique_id(self) -> str | None: + """Return the config entry unique id.""" + return self._unique_id @property - def device_info(self) -> DeviceInfo: + def device_name(self) -> str: + """Device name for the rainbird controller.""" + return f"{MANUFACTURER} Controller" + + @property + def device_info(self) -> DeviceInfo | None: """Return information about the device.""" + if not self._unique_id: + return None return DeviceInfo( - name=f"{MANUFACTURER} Controller", - identifiers={(DOMAIN, self._serial_number)}, + name=self.device_name, + identifiers={(DOMAIN, self._unique_id)}, manufacturer=MANUFACTURER, model=self._model_info.model_name, sw_version=f"{self._model_info.major}.{self._model_info.minor}", @@ -164,7 +171,7 @@ class RainbirdData: self.hass, name=self.entry.title, controller=self.controller, - serial_number=self.entry.data[CONF_SERIAL_NUMBER], + unique_id=self.entry.unique_id, model_info=self.model_info, ) diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index d0945609a1b..1e72fabafcd 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -51,8 +51,11 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.serial_number}-rain-delay" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = f"{coordinator.device_name} Rain delay" @property def native_value(self) -> float | None: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index 32eb053f478..d44e7156cb5 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -52,8 +52,13 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity) """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{coordinator.serial_number}-{description.key}" - self._attr_device_info = coordinator.device_info + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_device_info = coordinator.device_info + else: + self._attr_name = ( + f"{coordinator.device_name} {description.key.capitalize()}" + ) @property def native_value(self) -> StateType: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index cafc541d860..62b3b0e9a8c 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -65,20 +65,23 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone + if coordinator.unique_id: + self._attr_unique_id = f"{coordinator.unique_id}-{zone}" + device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None + self._attr_name = None if coordinator.unique_id else device_name self._attr_has_entity_name = True self._duration_minutes = duration_minutes - self._attr_unique_id = f"{coordinator.serial_number}-{zone}" - self._attr_device_info = DeviceInfo( - name=f"{MANUFACTURER} Sprinkler {zone}", - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer=MANUFACTURER, - via_device=(DOMAIN, coordinator.serial_number), - ) + if coordinator.unique_id and self._attr_unique_id: + self._attr_device_info = DeviceInfo( + name=device_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=MANUFACTURER, + via_device=(DOMAIN, coordinator.unique_id), + ) @property def extra_state_attributes(self): diff --git a/tests/components/rainbird/conftest.py b/tests/components/rainbird/conftest.py index dbc3456117c..f25bdfb1d86 100644 --- a/tests/components/rainbird/conftest.py +++ b/tests/components/rainbird/conftest.py @@ -86,7 +86,7 @@ def yaml_config() -> dict[str, Any]: @pytest.fixture -async def unique_id() -> str: +async def config_entry_unique_id() -> str: """Fixture for serial number used in the config entry.""" return SERIAL_NUMBER @@ -100,13 +100,13 @@ async def config_entry_data() -> dict[str, Any]: @pytest.fixture async def config_entry( config_entry_data: dict[str, Any] | None, - unique_id: str, + config_entry_unique_id: str | None, ) -> MockConfigEntry | None: """Fixture for MockConfigEntry.""" if config_entry_data is None: return None return MockConfigEntry( - unique_id=unique_id, + unique_id=config_entry_unique_id, domain=DOMAIN, data=config_entry_data, options={ATTR_DURATION: DEFAULT_TRIGGER_TIME_MINUTES}, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index cfa2c4d2684..e372a10ae23 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -5,6 +5,7 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup @@ -25,6 +26,7 @@ async def test_rainsensor( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, expected_state: bool, ) -> None: """Test rainsensor binary sensor.""" @@ -38,3 +40,37 @@ async def test_rainsensor( "friendly_name": "Rain Bird Controller Rainsensor", "icon": "mdi:water", } + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rainsensor" + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test rainsensor binary sensor with no unique id.""" + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") + assert rainsensor is not None + assert ( + rainsensor.attributes.get("friendly_name") == "Rain Bird Controller Rainsensor" + ) + + entity_entry = entity_registry.async_get( + "binary_sensor.rain_bird_controller_rainsensor" + ) + assert not entity_entry diff --git a/tests/components/rainbird/test_calendar.py b/tests/components/rainbird/test_calendar.py index 2028fccc24f..2e486226a7b 100644 --- a/tests/components/rainbird/test_calendar.py +++ b/tests/components/rainbird/test_calendar.py @@ -14,6 +14,7 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import ComponentSetup, mock_response, mock_response_error @@ -176,6 +177,7 @@ async def test_event_state( freezer: FrozenDateTimeFactory, freeze_time: datetime.datetime, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: """Test calendar upcoming event state.""" freezer.move_to(freeze_time) @@ -196,6 +198,10 @@ async def test_event_state( } assert state.state == expected_state + entity = entity_registry.async_get(TEST_ENTITY) + assert entity + assert entity.unique_id == 1263613994342 + @pytest.mark.parametrize( ("model_and_version_response", "has_entity"), @@ -270,3 +276,27 @@ async def test_program_schedule_disabled( "friendly_name": "Rain Bird Controller", "icon": "mdi:sprinkler", } + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + get_events: GetEventsFn, + entity_registry: er.EntityRegistry, +) -> None: + """Test calendar entity with no unique id.""" + + assert await setup_integration() + + state = hass.states.get(TEST_ENTITY) + assert state is not None + assert state.attributes.get("friendly_name") == "Rain Bird Controller" + + entity_entry = entity_registry.async_get(TEST_ENTITY) + assert not entity_entry diff --git a/tests/components/rainbird/test_config_flow.py b/tests/components/rainbird/test_config_flow.py index e7337ad6508..cfc4ff3b5cb 100644 --- a/tests/components/rainbird/test_config_flow.py +++ b/tests/components/rainbird/test_config_flow.py @@ -106,7 +106,7 @@ async def test_controller_flow( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", "expected_config_entry", @@ -154,7 +154,7 @@ async def test_multiple_config_entries( @pytest.mark.parametrize( ( - "unique_id", + "config_entry_unique_id", "config_entry_data", "config_flow_responses", ), diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 6ce7d10c9f2..5d208f08a25 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from .conftest import ( ACK_ECHO, @@ -39,8 +39,9 @@ async def test_number_values( hass: HomeAssistant, setup_integration: ComponentSetup, expected_state: str, + entity_registry: er.EntityRegistry, ) -> None: - """Test sensor platform.""" + """Test number platform.""" assert await setup_integration() @@ -57,6 +58,10 @@ async def test_number_values( "unit_of_measurement": "d", } + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-rain-delay" + async def test_set_value( hass: HomeAssistant, @@ -127,3 +132,28 @@ async def test_set_value_error( ) assert len(aioclient_mock.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + (None), + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, +) -> None: + """Test number platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + assert ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert not entity_entry diff --git a/tests/components/rainbird/test_sensor.py b/tests/components/rainbird/test_sensor.py index 049a5f15c45..d8fb053c0ff 100644 --- a/tests/components/rainbird/test_sensor.py +++ b/tests/components/rainbird/test_sensor.py @@ -5,8 +5,9 @@ import pytest from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup +from .conftest import CONFIG_ENTRY_DATA, RAIN_DELAY, RAIN_DELAY_OFF, ComponentSetup @pytest.fixture @@ -22,6 +23,7 @@ def platforms() -> list[str]: async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, expected_state: str, ) -> None: """Test sensor platform.""" @@ -35,3 +37,46 @@ async def test_sensors( "friendly_name": "Rain Bird Controller Raindelay", "icon": "mdi:water-off", } + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert entity_entry + assert entity_entry.unique_id == "1263613994342-raindelay" + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "config_entry_data"), + [ + # Config entry setup without a unique id since it had no serial number + ( + None, + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + # Legacy case for old config entries with serial number 0 preserves old behavior + ( + "0", + { + **CONFIG_ENTRY_DATA, + "serial_number": 0, + }, + ), + ], +) +async def test_sensor_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + config_entry_unique_id: str | None, +) -> None: + """Test sensor platform with no unique id.""" + + assert await setup_integration() + + raindelay = hass.states.get("sensor.rain_bird_controller_raindelay") + assert raindelay is not None + assert raindelay.attributes.get("friendly_name") == "Rain Bird Controller Raindelay" + + entity_entry = entity_registry.async_get("sensor.rain_bird_controller_raindelay") + assert (entity_entry is None) == (config_entry_unique_id is None) diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 9ce5e799c92..46a875e8928 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rainbird import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .conftest import ( ACK_ECHO, @@ -57,6 +58,7 @@ async def test_no_zones( async def test_zones( hass: HomeAssistant, setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, ) -> None: """Test switch platform with fake data that creates 7 zones with one enabled.""" @@ -100,6 +102,10 @@ async def test_zones( assert not hass.states.get("switch.rain_bird_sprinkler_8") + # Verify unique id for one of the switches + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry.unique_id == "1263613994342-3" + async def test_switch_on( hass: HomeAssistant, @@ -275,3 +281,29 @@ async def test_switch_error( with pytest.raises(HomeAssistantError, match=expected_msg): await switch_common.async_turn_off(hass, "switch.rain_bird_sprinkler_3") await hass.async_block_till_done() + + +@pytest.mark.parametrize( + ("config_entry_unique_id"), + [ + None, + ], +) +async def test_no_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, +) -> None: + """Test an irrigation switch with no unique id.""" + + assert await setup_integration() + + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry is None From 598a8890e9dd6a9788c842d8d8843285fdabbbac Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sun, 1 Oct 2023 18:22:19 +0200 Subject: [PATCH 088/968] Use freezer.tick in devolo_home_network image tests (#101208) Use freezer.tick --- tests/components/devolo_home_network/test_image.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/devolo_home_network/test_image.py b/tests/components/devolo_home_network/test_image.py index b8fb491e1ec..ef7c4b2bbba 100644 --- a/tests/components/devolo_home_network/test_image.py +++ b/tests/components/devolo_home_network/test_image.py @@ -68,8 +68,8 @@ async def test_guest_wifi_qr( # Emulate device failure mock_device.device.async_get_wifi_guest_access.side_effect = DeviceUnavailable() - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -80,8 +80,8 @@ async def test_guest_wifi_qr( mock_device.device.async_get_wifi_guest_access = AsyncMock( return_value=GUEST_WIFI_CHANGED ) - freezer.move_to(dt_util.utcnow() + SHORT_UPDATE_INTERVAL) - async_fire_time_changed(hass, dt_util.utcnow() + SHORT_UPDATE_INTERVAL) + freezer.tick(SHORT_UPDATE_INTERVAL) + async_fire_time_changed(hass) await hass.async_block_till_done() state = hass.states.get(state_key) From 65c8da3bf1e753d8a5eb53be58e8bdb0ff0cd84f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 18:28:53 +0200 Subject: [PATCH 089/968] Correct JSONDecodeError in co2signal (#101206) --- homeassistant/components/co2signal/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index c210d989c04..24d7bbd18af 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -3,11 +3,11 @@ from __future__ import annotations from collections.abc import Mapping from datetime import timedelta -from json import JSONDecodeError import logging from typing import Any, cast import CO2Signal +from requests.exceptions import JSONDecodeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE From 8fd0a1b0836b538295f987a284a44322bf822376 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 18:29:53 +0200 Subject: [PATCH 090/968] Add config entry name to Withings webhook name (#101205) --- homeassistant/components/withings/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 44d32b0603c..aaef7bdb142 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -41,7 +41,14 @@ from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_CLOUDHOOK_URL, CONF_PROFILES, CONF_USE_WEBHOOK, DOMAIN, LOGGER +from .const import ( + CONF_CLOUDHOOK_URL, + CONF_PROFILES, + CONF_USE_WEBHOOK, + DEFAULT_TITLE, + DOMAIN, + LOGGER, +) from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -151,10 +158,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return + webhook_name = "Withings" + if entry.title != DEFAULT_TITLE: + webhook_name += " ".join([webhook_name, entry.title]) + webhook_register( hass, DOMAIN, - "Withings", + webhook_name, entry.data[CONF_WEBHOOK_ID], get_webhook_handler(coordinator), ) From 1db3d3c158bd23bee274816eda6461c66b20087f Mon Sep 17 00:00:00 2001 From: RoboMagus <68224306+RoboMagus@users.noreply.github.com> Date: Sun, 1 Oct 2023 20:17:53 +0200 Subject: [PATCH 091/968] icon for commandline sensors (#101195) * Add icon to schema for commandline sensor and binary_sensor * Add icon tests --- homeassistant/components/command_line/__init__.py | 2 ++ homeassistant/components/command_line/binary_sensor.py | 3 +++ tests/components/command_line/test_binary_sensor.py | 4 ++++ tests/components/command_line/test_sensor.py | 2 ++ 4 files changed, 11 insertions(+) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 6f536bf4744..5d057d40e1b 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -80,6 +80,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( { vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, @@ -119,6 +120,7 @@ SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_ICON): cv.template, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 1d6ee9046e8..3ccd0bd1503 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -16,6 +16,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, + CONF_ICON, CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, @@ -86,6 +87,7 @@ async def async_setup_platform( device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( CONF_DEVICE_CLASS ) + icon: Template | None = binary_sensor_config.get(CONF_ICON) value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) @@ -100,6 +102,7 @@ async def async_setup_platform( CONF_UNIQUE_ID: unique_id, CONF_NAME: Template(name, hass), CONF_DEVICE_CLASS: device_class, + CONF_ICON: icon, } async_add_entities( diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index 9787728965e..360c78dd5a7 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -93,6 +93,9 @@ async def test_setup_integration_yaml( "payload_on": "1.0", "payload_off": "0", "value_template": "{{ value | multiply(0.1) }}", + "icon": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), } } ] @@ -105,6 +108,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index da2bf1f6dd9..388d0345cad 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -93,6 +93,7 @@ async def test_setup_integration_yaml( "command": "echo 50", "unit_of_measurement": "in", "value_template": "{{ value | multiply(0.1) }}", + "icon": "mdi:console", } } ] @@ -105,6 +106,7 @@ async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> Non entity_state = hass.states.get("sensor.test") assert entity_state assert float(entity_state.state) == 5 + assert entity_state.attributes.get("icon") == "mdi:console" @pytest.mark.parametrize( From 1f76abe6f4c549d28526389b83ded6a2a14916fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Oct 2023 19:33:38 +0100 Subject: [PATCH 092/968] Bump zeroconf to 0.115.1 (#101213) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 9898c6a3496..53475588cfe 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.115.0"] + "requirements": ["zeroconf==0.115.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d6f923f0047..659caa1078d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.0 +zeroconf==0.115.1 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 1ee07117d1e..80e7f069647 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95ec8b1a9a5..36cced81c4b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2078,7 +2078,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.0 +zeroconf==0.115.1 # homeassistant.components.zeversolar zeversolar==0.3.1 From 377f00730a0aeff76f6c01b73dc5abfc66da9237 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 1 Oct 2023 19:33:53 +0100 Subject: [PATCH 093/968] Bump aioesphomeapi to 17.0.1 (#101214) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d6fdd971fa6..8169eeb70e3 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.0.0", + "aioesphomeapi==17.0.1", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 80e7f069647..5693910f5b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -231,7 +231,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36cced81c4b..7d9ba5b3634 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,7 +212,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.0 +aioesphomeapi==17.0.1 # homeassistant.components.flo aioflo==2021.11.0 From 67f7c703f48ce8bfd2c1511757062a3326cbfc78 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 20:55:00 +0200 Subject: [PATCH 094/968] Fix withings webhook name (#101221) --- homeassistant/components/withings/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index aaef7bdb142..246bcc134d0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -160,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_name = "Withings" if entry.title != DEFAULT_TITLE: - webhook_name += " ".join([webhook_name, entry.title]) + webhook_name = " ".join([DEFAULT_TITLE, entry.title]) webhook_register( hass, From 9261ad14e26ca0907681d5e8bd3a4f362fafb62f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 1 Oct 2023 20:56:34 +0200 Subject: [PATCH 095/968] Add RestoreEntity to Comelit cover (#101192) * Add RestoreEntoty to Comelit cover * remove logging --- homeassistant/components/comelit/cover.py | 41 ++++++++++++++++++----- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 0135fa3984a..48478f075d3 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -6,10 +6,11 @@ from typing import Any from aiocomelit import ComelitSerialBridgeObject from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS -from homeassistant.components.cover import CoverDeviceClass, CoverEntity +from homeassistant.components.cover import STATE_CLOSED, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -32,7 +33,9 @@ async def async_setup_entry( ) -class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): +class ComelitCoverEntity( + CoordinatorEntity[ComelitSerialBridge], RestoreEntity, CoverEntity +): """Cover device.""" _attr_device_class = CoverDeviceClass.SHUTTER @@ -51,8 +54,9 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): super().__init__(coordinator) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device, COVER) - # Device doesn't provide a status so we assume CLOSE at startup - self._last_action = COVER_STATUS.index("closing") + # Device doesn't provide a status so we assume UNKNOWN at first startup + self._last_action: int | None = None + self._last_state: str | None = None def _current_action(self, action: str) -> bool: """Return the current cover action.""" @@ -67,12 +71,19 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): return self.coordinator.data[COVER][self._device.index].status @property - def is_closed(self) -> bool: - """Return True if cover is closed.""" + def is_closed(self) -> bool | None: + """Return if the cover is closed.""" + + if self._last_state in [None, "unknown"]: + return None + if self.device_status != COVER_STATUS.index("stopped"): return False - return bool(self._last_action == COVER_STATUS.index("closing")) + if self._last_action: + return self._last_action == COVER_STATUS.index("closing") + + return self._last_state == STATE_CLOSED @property def is_closing(self) -> bool: @@ -99,3 +110,17 @@ class ComelitCoverEntity(CoordinatorEntity[ComelitSerialBridge], CoverEntity): action = COVER_OPEN if self.is_closing else COVER_CLOSE await self._api.cover_move(self._device.index, action) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle device update.""" + self._last_state = self.state + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + + await super().async_added_to_hass() + + if last_state := await self.async_get_last_state(): + self._last_state = last_state.state From cabfbc245d662aab6b5bbddd3ee9f5e5bebb9fc2 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Sun, 1 Oct 2023 14:20:09 -0700 Subject: [PATCH 096/968] Add weatherkit sensor platform (#101150) * Add weatherkit sensor platform and tests * Make unique ID assignment more explicit * Fix missing argument * Use const for top-level API response keys * Address code review feedback --- .../components/weatherkit/__init__.py | 2 +- homeassistant/components/weatherkit/const.py | 6 ++ homeassistant/components/weatherkit/entity.py | 33 +++++++++ homeassistant/components/weatherkit/sensor.py | 73 +++++++++++++++++++ .../components/weatherkit/strings.json | 12 +++ .../components/weatherkit/weather.py | 33 ++++----- .../weatherkit/fixtures/weather_response.json | 2 +- tests/components/weatherkit/test_sensor.py | 27 +++++++ 8 files changed, 167 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/weatherkit/entity.py create mode 100644 homeassistant/components/weatherkit/sensor.py create mode 100644 tests/components/weatherkit/test_sensor.py diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py index fb41ffc1084..15ad5fa2ffb 100644 --- a/homeassistant/components/weatherkit/__init__.py +++ b/homeassistant/components/weatherkit/__init__.py @@ -23,7 +23,7 @@ from .const import ( ) from .coordinator import WeatherKitDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.WEATHER] +PLATFORMS: list[Platform] = [Platform.WEATHER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py index 590ca65c9a9..e35dd33c561 100644 --- a/homeassistant/components/weatherkit/const.py +++ b/homeassistant/components/weatherkit/const.py @@ -10,7 +10,13 @@ ATTRIBUTION = ( "https://developer.apple.com/weatherkit/data-source-attribution/" ) +MANUFACTURER = "Apple Weather" + CONF_KEY_ID = "key_id" CONF_SERVICE_ID = "service_id" CONF_TEAM_ID = "team_id" CONF_KEY_PEM = "key_pem" + +ATTR_CURRENT_WEATHER = "currentWeather" +ATTR_FORECAST_HOURLY = "forecastHourly" +ATTR_FORECAST_DAILY = "forecastDaily" diff --git a/homeassistant/components/weatherkit/entity.py b/homeassistant/components/weatherkit/entity.py new file mode 100644 index 00000000000..a244c9c4525 --- /dev/null +++ b/homeassistant/components/weatherkit/entity.py @@ -0,0 +1,33 @@ +"""Base entity for weatherkit.""" + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import WeatherKitDataUpdateCoordinator + + +class WeatherKitEntity(Entity): + """Base entity for all WeatherKit platforms.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: WeatherKitDataUpdateCoordinator, unique_id_suffix: str | None + ) -> None: + """Initialize the entity with device info and unique ID.""" + config_data = coordinator.config_entry.data + + config_entry_unique_id = ( + f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" + ) + self._attr_unique_id = config_entry_unique_id + if unique_id_suffix is not None: + self._attr_unique_id += f"_{unique_id_suffix}" + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, config_entry_unique_id)}, + manufacturer=MANUFACTURER, + ) diff --git a/homeassistant/components/weatherkit/sensor.py b/homeassistant/components/weatherkit/sensor.py new file mode 100644 index 00000000000..38b4a60cba5 --- /dev/null +++ b/homeassistant/components/weatherkit/sensor.py @@ -0,0 +1,73 @@ +"""WeatherKit sensors.""" + + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolumetricFlux +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTR_CURRENT_WEATHER, DOMAIN +from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity + +SENSORS = ( + SensorEntityDescription( + key="precipitationIntensity", + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + ), + SensorEntityDescription( + key="pressureTrend", + device_class=SensorDeviceClass.ENUM, + icon="mdi:gauge", + options=["rising", "falling", "steady"], + translation_key="pressure_trend", + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add sensor entities from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities( + WeatherKitSensor(coordinator, description) for description in SENSORS + ) + + +class WeatherKitSensor( + CoordinatorEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity, SensorEntity +): + """WeatherKit sensor entity.""" + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + WeatherKitEntity.__init__( + self, coordinator, unique_id_suffix=entity_description.key + ) + self.entity_description = entity_description + + @property + def native_value(self) -> StateType: + """Return native value from coordinator current weather.""" + return self.coordinator.data[ATTR_CURRENT_WEATHER][self.entity_description.key] diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json index 4581028f209..a0b62a5e16f 100644 --- a/homeassistant/components/weatherkit/strings.json +++ b/homeassistant/components/weatherkit/strings.json @@ -21,5 +21,17 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } + }, + "entity": { + "sensor": { + "pressure_trend": { + "name": "Pressure trend", + "state": { + "steady": "Steady", + "rising": "Rising", + "falling": "Falling" + } + } + } } } diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index ce997fa500f..98816d520ba 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -23,19 +23,23 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, UnitOfLength, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTRIBUTION, DOMAIN +from .const import ( + ATTR_CURRENT_WEATHER, + ATTR_FORECAST_DAILY, + ATTR_FORECAST_HOURLY, + ATTRIBUTION, + DOMAIN, +) from .coordinator import WeatherKitDataUpdateCoordinator +from .entity import WeatherKitEntity async def async_setup_entry( @@ -121,13 +125,12 @@ def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: class WeatherKitWeather( - SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator], WeatherKitEntity ): """Weather entity for Apple WeatherKit integration.""" _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True _attr_name = None _attr_native_temperature_unit = UnitOfTemperature.CELSIUS @@ -140,17 +143,9 @@ class WeatherKitWeather( self, coordinator: WeatherKitDataUpdateCoordinator, ) -> None: - """Initialise the platform with a data instance and site.""" + """Initialize the platform with a coordinator.""" super().__init__(coordinator) - config_data = coordinator.config_entry.data - self._attr_unique_id = ( - f"{config_data[CONF_LATITUDE]}-{config_data[CONF_LONGITUDE]}" - ) - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._attr_unique_id)}, - manufacturer="Apple Weather", - ) + WeatherKitEntity.__init__(self, coordinator, unique_id_suffix=None) @property def supported_features(self) -> WeatherEntityFeature: @@ -174,7 +169,7 @@ class WeatherKitWeather( @property def current_weather(self) -> dict[str, Any]: """Return current weather data.""" - return self.data["currentWeather"] + return self.data[ATTR_CURRENT_WEATHER] @property def condition(self) -> str | None: @@ -245,7 +240,7 @@ class WeatherKitWeather( @callback def _async_forecast_daily(self) -> list[Forecast] | None: """Return the daily forecast.""" - daily_forecast = self.data.get("forecastDaily") + daily_forecast = self.data.get(ATTR_FORECAST_DAILY) if not daily_forecast: return None @@ -255,7 +250,7 @@ class WeatherKitWeather( @callback def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast.""" - hourly_forecast = self.data.get("forecastHourly") + hourly_forecast = self.data.get(ATTR_FORECAST_HOURLY) if not hourly_forecast: return None diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json index c2d619d85d8..7a38347bdf5 100644 --- a/tests/components/weatherkit/fixtures/weather_response.json +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -19,7 +19,7 @@ "conditionCode": "PartlyCloudy", "daylight": true, "humidity": 0.91, - "precipitationIntensity": 0.0, + "precipitationIntensity": 0.7, "pressure": 1009.8, "pressureTrend": "rising", "temperature": 22.9, diff --git a/tests/components/weatherkit/test_sensor.py b/tests/components/weatherkit/test_sensor.py new file mode 100644 index 00000000000..6c6999c6bfd --- /dev/null +++ b/tests/components/weatherkit/test_sensor.py @@ -0,0 +1,27 @@ +"""Sensor entity tests for the WeatherKit integration.""" + +from typing import Any + +import pytest + +from homeassistant.core import HomeAssistant + +from . import init_integration + + +@pytest.mark.parametrize( + ("entity_name", "expected_value"), + [ + ("sensor.home_precipitation_intensity", 0.7), + ("sensor.home_pressure_trend", "rising"), + ], +) +async def test_sensor_values( + hass: HomeAssistant, entity_name: str, expected_value: Any +) -> None: + """Test that various sensor values match what we expect.""" + await init_integration(hass) + + state = hass.states.get(entity_name) + assert state + assert state.state == str(expected_value) From 4c24ff6847d3bcb22ed3fef2c10c4e8d32240150 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 1 Oct 2023 23:47:32 +0200 Subject: [PATCH 097/968] Migrate WAQI to has entity name (#101203) --- homeassistant/components/waqi/sensor.py | 9 ++++++++- tests/components/waqi/test_sensor.py | 6 +++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 62170b329f4..e0ecf5827d8 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -154,12 +155,18 @@ class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT + _attr_has_entity_name = True + _attr_name = None def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_name = f"WAQI {self.coordinator.data.city.name}" self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(coordinator.data.station_id))}, + name=coordinator.data.city.name, + entry_type=DeviceEntryType.SERVICE, + ) @property def native_value(self) -> int | None: diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 7feb37a1b09..46bd577c48f 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -72,7 +72,7 @@ async def test_legacy_migration_already_imported( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht") assert state.state == "29" hass.async_create_task( @@ -116,7 +116,7 @@ async def test_sensor_id_migration( ) assert len(entities) == 1 assert hass.states.get("sensor.waqi_4584") - assert hass.states.get("sensor.waqi_de_jongweg_utrecht") is None + assert hass.states.get("sensor.de_jongweg_utrecht") is None assert entities[0].unique_id == "4584_air_quality" @@ -132,7 +132,7 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht") assert state.state == "29" From 4e4b8de448e1f00ec36a16e65bf979aa0c24f42f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 1 Oct 2023 23:09:08 -0700 Subject: [PATCH 098/968] Add reauth support in fitbit (#101178) * Add reauth support in fitbit * Update tests/components/fitbit/test_config_flow.py Co-authored-by: Martin Hjelmare * Improve http status error code handling --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/__init__.py | 8 +- .../components/fitbit/config_flow.py | 28 +++- homeassistant/components/fitbit/strings.json | 8 +- tests/components/fitbit/test_config_flow.py | 146 ++++++++++++++++++ tests/components/fitbit/test_init.py | 35 ++++- 5 files changed, 220 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 2a7b58d7d76..522754de5d6 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1,11 +1,13 @@ """The fitbit component.""" +from http import HTTPStatus + import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api @@ -29,6 +31,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await fitbit_api.async_get_access_token() + except aiohttp.ClientResponseError as err: + if err.status == HTTPStatus.UNAUTHORIZED: + raise ConfigEntryAuthFailed from err + raise ConfigEntryNotReady from err except aiohttp.ClientError as err: raise ConfigEntryNotReady from err diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index d391660df97..ff9cf6cd17c 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -1,10 +1,12 @@ """Config flow for fitbit.""" +from collections.abc import Mapping import logging from typing import Any from fitbit.exceptions import HTTPException +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -22,6 +24,8 @@ class OAuth2FlowHandler( DOMAIN = DOMAIN + reauth_entry: ConfigEntry | None = None + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -32,9 +36,24 @@ class OAuth2FlowHandler( """Extra data that needs to be appended to the authorize url.""" return { "scope": " ".join(OAUTH_SCOPES), - "prompt": "consent", + "prompt": "consent" if not self.reauth_entry else "none", } + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" @@ -45,6 +64,13 @@ class OAuth2FlowHandler( _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) return self.async_abort(reason="cannot_connect") + if self.reauth_entry: + if self.reauth_entry.unique_id != profile.encoded_id: + return self.async_abort(reason="wrong_account") + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + await self.async_set_unique_id(profile.encoded_id) self._abort_if_unique_id_configured() return self.async_create_entry(title=profile.full_name, data=data) diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 240f34154ae..2d74408a73f 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -6,6 +6,10 @@ }, "auth": { "title": "Link Fitbit" + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Fitbit integration needs to re-authenticate your account" } }, "abort": { @@ -15,7 +19,9 @@ "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "wrong_account": "The user credentials provided do not match this Fitbit account." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 0418f7da0f4..df4bae89b47 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus from unittest.mock import patch +import pytest from requests_mock.mocker import Mocker from homeassistant import config_entries @@ -313,3 +314,148 @@ async def test_platform_setup_without_import( issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) assert issue assert issue.translation_key == "deprecated_yaml_no_import" + + +async def test_reauth_flow( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication flow will update existing config entry.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # config_entry.req initiates reauth + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "reauth_successful" + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert config_entry.data["token"]["refresh_token"] == "updated-refresh-token" + + +@pytest.mark.parametrize("profile_id", ["other-user-id"]) +async def test_reauth_wrong_user_id( + hass: HomeAssistant, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, +) -> None: + """Test OAuth reauthentication where the wrong user is selected.""" + config_entry.add_to_hass(hass) + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], + user_input={}, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=none" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "updated-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "wrong_account" + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 65a7587f736..32dc9b0cc98 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -43,18 +43,26 @@ async def test_setup( assert config_entry.state == ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize("token_expiration_time", [12345]) +@pytest.mark.parametrize( + ("token_expiration_time", "server_status"), + [ + (12345, HTTPStatus.INTERNAL_SERVER_ERROR), + (12345, HTTPStatus.FORBIDDEN), + (12345, HTTPStatus.NOT_FOUND), + ], +) async def test_token_refresh_failure( integration_setup: Callable[[], Awaitable[bool]], config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_credentials: None, + server_status: HTTPStatus, ) -> None: """Test where token is expired and the refresh attempt fails and will be retried.""" aioclient_mock.post( OAUTH2_TOKEN, - status=HTTPStatus.INTERNAL_SERVER_ERROR, + status=server_status, ) assert not await integration_setup() @@ -94,3 +102,26 @@ async def test_token_refresh_success( config_entry.data["token"]["access_token"] == SERVER_ACCESS_TOKEN["access_token"] ) + + +@pytest.mark.parametrize("token_expiration_time", [12345]) +async def test_token_requires_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, +) -> None: + """Test where token is expired and the refresh attempt requires reauth.""" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=HTTPStatus.UNAUTHORIZED, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" From c1cfce116d308d7890934e276a4e6fd2f92df5a8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Oct 2023 08:55:35 +0200 Subject: [PATCH 099/968] Bump pytrafikverket to 0.3.7 (#101231) --- homeassistant/components/trafikverket_camera/manifest.json | 2 +- homeassistant/components/trafikverket_ferry/manifest.json | 2 +- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index d23631c6878..7b457063c6c 100644 --- a/homeassistant/components/trafikverket_camera/manifest.json +++ b/homeassistant/components/trafikverket_camera/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_camera", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 9d0b904290c..7d0171bc8bb 100644 --- a/homeassistant/components/trafikverket_ferry/manifest.json +++ b/homeassistant/components/trafikverket_ferry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_ferry", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index ab1f7feb3f7..f81c2e0bf76 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 138af544066..cb16cd62d36 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", "loggers": ["pytrafikverket"], - "requirements": ["pytrafikverket==0.3.6"] + "requirements": ["pytrafikverket==0.3.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5693910f5b4..c5b3316072b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2213,7 +2213,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.6 +pytrafikverket==0.3.7 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d9ba5b3634..3338def76f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1648,7 +1648,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.6 +pytrafikverket==0.3.7 # homeassistant.components.usb pyudev==0.23.2 From 78f827697e7578fef1d313cb437760eba1157a43 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 2 Oct 2023 08:57:16 +0200 Subject: [PATCH 100/968] Remove imap_email_content integration (#101233) --- .coveragerc | 1 - homeassistant/components/imap/config_flow.py | 30 +- .../components/imap_email_content/__init__.py | 17 - .../components/imap_email_content/const.py | 13 - .../imap_email_content/manifest.json | 8 - .../components/imap_email_content/repairs.py | 173 ---------- .../components/imap_email_content/sensor.py | 302 ------------------ .../imap_email_content/strings.json | 27 -- homeassistant/generated/integrations.json | 6 - tests/components/imap/test_config_flow.py | 67 ---- .../components/imap_email_content/__init__.py | 1 - .../imap_email_content/test_repairs.py | 296 ----------------- .../imap_email_content/test_sensor.py | 253 --------------- 13 files changed, 1 insertion(+), 1193 deletions(-) delete mode 100644 homeassistant/components/imap_email_content/__init__.py delete mode 100644 homeassistant/components/imap_email_content/const.py delete mode 100644 homeassistant/components/imap_email_content/manifest.json delete mode 100644 homeassistant/components/imap_email_content/repairs.py delete mode 100644 homeassistant/components/imap_email_content/sensor.py delete mode 100644 homeassistant/components/imap_email_content/strings.json delete mode 100644 tests/components/imap_email_content/__init__.py delete mode 100644 tests/components/imap_email_content/test_repairs.py delete mode 100644 tests/components/imap_email_content/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 533fd8de18d..7f474426fa2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -563,7 +563,6 @@ omit = homeassistant/components/ifttt/alarm_control_panel.py homeassistant/components/iglo/light.py homeassistant/components/ihc/* - homeassistant/components/imap_email_content/sensor.py homeassistant/components/incomfort/* homeassistant/components/insteon/binary_sensor.py homeassistant/components/insteon/climate.py diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 4c4a2e2a35c..70594d5fd7c 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,13 +10,7 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv @@ -132,28 +126,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: config_entries.ConfigEntry | None - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle the import from imap_email_content integration.""" - data = CONFIG_SCHEMA( - { - CONF_SERVER: user_input[CONF_SERVER], - CONF_PORT: user_input[CONF_PORT], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_FOLDER: user_input[CONF_FOLDER], - } - ) - self._async_abort_entries_match( - { - key: data[key] - for key in (CONF_USERNAME, CONF_SERVER, CONF_FOLDER, CONF_SEARCH) - } - ) - title = user_input[CONF_NAME] - if await validate_input(self.hass, data): - raise AbortFlow("cannot_connect") - return self.async_create_entry(title=title, data=data) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py deleted file mode 100644 index f2041b947df..00000000000 --- a/homeassistant/components/imap_email_content/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""The imap_email_content component.""" - -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType - -from .const import DOMAIN - -PLATFORMS = [Platform.SENSOR] - -CONFIG_SCHEMA = cv.deprecated(DOMAIN, raise_if_present=False) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up imap_email_content.""" - return True diff --git a/homeassistant/components/imap_email_content/const.py b/homeassistant/components/imap_email_content/const.py deleted file mode 100644 index 5f1c653030e..00000000000 --- a/homeassistant/components/imap_email_content/const.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Constants for the imap email content integration.""" - -DOMAIN = "imap_email_content" - -CONF_SERVER = "server" -CONF_SENDERS = "senders" -CONF_FOLDER = "folder" - -ATTR_FROM = "from" -ATTR_BODY = "body" -ATTR_SUBJECT = "subject" - -DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json deleted file mode 100644 index b7d0589b83f..00000000000 --- a/homeassistant/components/imap_email_content/manifest.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "domain": "imap_email_content", - "name": "IMAP Email Content", - "codeowners": [], - "dependencies": ["imap"], - "documentation": "https://www.home-assistant.io/integrations/imap_email_content", - "iot_class": "cloud_push" -} diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py deleted file mode 100644 index 8fe05f80c08..00000000000 --- a/homeassistant/components/imap_email_content/repairs.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Repair flow for imap email content integration.""" - -from typing import Any - -import voluptuous as vol -import yaml - -from homeassistant import data_entry_flow -from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN -from homeassistant.components.repairs import RepairsFlow -from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers import issue_registry as ir -from homeassistant.helpers.typing import ConfigType - -from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN - - -async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: - """Register an issue and suggest new config.""" - - name: str = config.get(CONF_NAME) or config[CONF_USERNAME] - - issue_id = ( - f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" - ) - - if CONF_VALUE_TEMPLATE in config: - template: str = config[CONF_VALUE_TEMPLATE].template - template = template.replace("subject", 'trigger.event.data["subject"]') - template = template.replace("from", 'trigger.event.data["sender"]') - template = template.replace("date", 'trigger.event.data["date"]') - template = template.replace("body", 'trigger.event.data["text"]') - else: - template = '{{ trigger.event.data["subject"] }}' - - template_sensor_config: ConfigType = { - "template": [ - { - "trigger": [ - { - "id": "custom_event", - "platform": "event", - "event_type": "imap_content", - "event_data": {"sender": config[CONF_SENDERS][0]}, - } - ], - "sensor": [ - { - "state": template, - "name": name, - } - ], - } - ] - } - - data = { - CONF_SERVER: config[CONF_SERVER], - CONF_PORT: config[CONF_PORT], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_FOLDER: config[CONF_FOLDER], - } - data[CONF_VALUE_TEMPLATE] = template - data[CONF_NAME] = name - placeholders = {"yaml_example": yaml.dump(template_sensor_config)} - placeholders.update(data) - - ir.async_create_issue( - hass, - DOMAIN, - issue_id, - breaks_in_ha_version="2023.11.0", - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="migration", - translation_placeholders=placeholders, - data=data, - ) - - -class DeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, issue_id: str, config: ConfigType) -> None: - """Create flow.""" - self._name: str = config[CONF_NAME] - self._config: dict[str, Any] = config - self._issue_id = issue_id - super().__init__() - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_start() - - @callback - def _async_get_placeholders(self) -> dict[str, str] | None: - issue_registry = ir.async_get(self.hass) - description_placeholders = None - if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders - - return description_placeholders - - async def async_step_start( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Wait for the user to start the config migration.""" - placeholders = self._async_get_placeholders() - if user_input is None: - return self.async_show_form( - step_id="start", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - placeholders = self._async_get_placeholders() - if user_input is not None: - user_input[CONF_NAME] = self._name - result = await self.hass.config_entries.flow.async_init( - IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) - ir.async_create_issue( - self.hass, - DOMAIN, - self._issue_id, - breaks_in_ha_version="2023.11.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecation", - translation_placeholders=placeholders, - data=self._config, - learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", - ) - return self.async_abort(reason=result["reason"]) - return self.async_create_entry( - title="", - data={}, - ) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None], -) -> RepairsFlow: - """Create flow.""" - return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py deleted file mode 100644 index 1df207e2968..00000000000 --- a/homeassistant/components/imap_email_content/sensor.py +++ /dev/null @@ -1,302 +0,0 @@ -"""Email sensor support.""" -from __future__ import annotations - -from collections import deque -import datetime -import email -import imaplib -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_DATE, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, - CONTENT_TYPE_TEXT_PLAIN, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.ssl import client_context - -from .const import ( - ATTR_BODY, - ATTR_FROM, - ATTR_SUBJECT, - CONF_FOLDER, - CONF_SENDERS, - CONF_SERVER, - DEFAULT_PORT, -) -from .repairs import async_process_issue - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_SERVER): cv.string, - vol.Required(CONF_SENDERS): [cv.string], - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - } -) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Email sensor platform.""" - reader = EmailReader( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_SERVER], - config[CONF_PORT], - config[CONF_FOLDER], - config[CONF_VERIFY_SSL], - ) - - if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: - value_template.hass = hass - sensor = EmailContentSensor( - hass, - reader, - config.get(CONF_NAME) or config[CONF_USERNAME], - config[CONF_SENDERS], - value_template, - ) - - hass.add_job(async_process_issue, hass, config) - - if sensor.connected: - add_entities([sensor], True) - - -class EmailReader: - """A class to read emails from an IMAP server.""" - - def __init__(self, user, password, server, port, folder, verify_ssl): - """Initialize the Email Reader.""" - self._user = user - self._password = password - self._server = server - self._port = port - self._folder = folder - self._verify_ssl = verify_ssl - self._last_id = None - self._last_message = None - self._unread_ids = deque([]) - self.connection = None - - @property - def last_id(self) -> int | None: - """Return last email uid that was processed.""" - return self._last_id - - @property - def last_unread_id(self) -> int | None: - """Return last email uid received.""" - # We assume the last id in the list is the last unread id - # We cannot know if that is the newest one, because it could arrive later - # https://stackoverflow.com/questions/12409862/python-imap-the-order-of-uids - if self._unread_ids: - return int(self._unread_ids[-1]) - return self._last_id - - def connect(self): - """Login and setup the connection.""" - ssl_context = client_context() if self._verify_ssl else None - try: - self.connection = imaplib.IMAP4_SSL( - self._server, self._port, ssl_context=ssl_context - ) - self.connection.login(self._user, self._password) - return True - except imaplib.IMAP4.error: - _LOGGER.error("Failed to login to %s", self._server) - return False - - def _fetch_message(self, message_uid): - """Get an email message from a message id.""" - _, message_data = self.connection.uid("fetch", message_uid, "(RFC822)") - - if message_data is None: - return None - if message_data[0] is None: - return None - raw_email = message_data[0][1] - email_message = email.message_from_bytes(raw_email) - return email_message - - def read_next(self): - """Read the next email from the email server.""" - try: - self.connection.select(self._folder, readonly=True) - - if self._last_id is None: - # search for today and yesterday - time_from = datetime.datetime.now() - datetime.timedelta(days=1) - search = f"SINCE {time_from:%d-%b-%Y}" - else: - search = f"UID {self._last_id}:*" - - _, data = self.connection.uid("search", None, search) - self._unread_ids = deque(data[0].split()) - while self._unread_ids: - message_uid = self._unread_ids.popleft() - if self._last_id is None or int(message_uid) > self._last_id: - self._last_id = int(message_uid) - self._last_message = self._fetch_message(message_uid) - return self._last_message - - except imaplib.IMAP4.error: - _LOGGER.info("Connection to %s lost, attempting to reconnect", self._server) - try: - self.connect() - _LOGGER.info( - "Reconnect to %s succeeded, trying last message", self._server - ) - if self._last_id is not None: - return self._fetch_message(str(self._last_id)) - except imaplib.IMAP4.error: - _LOGGER.error("Failed to reconnect") - - return None - - -class EmailContentSensor(SensorEntity): - """Representation of an EMail sensor.""" - - def __init__(self, hass, email_reader, name, allowed_senders, value_template): - """Initialize the sensor.""" - self.hass = hass - self._email_reader = email_reader - self._name = name - self._allowed_senders = [sender.upper() for sender in allowed_senders] - self._value_template = value_template - self._last_id = None - self._message = None - self._state_attributes = None - self.connected = self._email_reader.connect() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the current email state.""" - return self._message - - @property - def extra_state_attributes(self): - """Return other state attributes for the message.""" - return self._state_attributes - - def render_template(self, email_message): - """Render the message template.""" - variables = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - return self._value_template.render(variables, parse_result=False) - - def sender_allowed(self, email_message): - """Check if the sender is in the allowed senders list.""" - return EmailContentSensor.get_msg_sender(email_message).upper() in ( - sender for sender in self._allowed_senders - ) - - @staticmethod - def get_msg_sender(email_message): - """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(email_message["From"])[1]) - - @staticmethod - def get_msg_subject(email_message): - """Decode the message subject.""" - decoded_header = email.header.decode_header(email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) - - @staticmethod - def get_msg_text(email_message): - """Get the message text from the email. - - Will look for text/plain or use text/html if not found. - """ - message_text = None - message_html = None - message_untyped_text = None - - for part in email_message.walk(): - if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: - if message_text is None: - message_text = part.get_payload() - elif part.get_content_type() == "text/html": - if message_html is None: - message_html = part.get_payload() - elif ( - part.get_content_type().startswith("text") - and message_untyped_text is None - ): - message_untyped_text = part.get_payload() - - if message_text is not None: - return message_text - - if message_html is not None: - return message_html - - if message_untyped_text is not None: - return message_untyped_text - - return email_message.get_payload() - - def update(self) -> None: - """Read emails and publish state change.""" - email_message = self._email_reader.read_next() - while ( - self._last_id is None or self._last_id != self._email_reader.last_unread_id - ): - if email_message is None: - self._message = None - self._state_attributes = {} - return - - self._last_id = self._email_reader.last_id - - if self.sender_allowed(email_message): - message = EmailContentSensor.get_msg_subject(email_message) - - if self._value_template is not None: - message = self.render_template(email_message) - - self._message = message - self._state_attributes = { - ATTR_FROM: EmailContentSensor.get_msg_sender(email_message), - ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message), - ATTR_DATE: email_message["Date"], - ATTR_BODY: EmailContentSensor.get_msg_text(email_message), - } - - if self._last_id == self._email_reader.last_unread_id: - break - email_message = self._email_reader.read_next() diff --git a/homeassistant/components/imap_email_content/strings.json b/homeassistant/components/imap_email_content/strings.json deleted file mode 100644 index b7b987b1212..00000000000 --- a/homeassistant/components/imap_email_content/strings.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "issues": { - "deprecation": { - "title": "The IMAP email content integration is deprecated", - "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap). To set up a sensor for the IMAP email content, set up a template sensor with the config:\n\n```yaml\n{yaml_example}```\n\nPlease remove the deprecated `imap_email_plaform` sensor configuration from your `configuration.yaml`.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\nYou can skip this part if you have already set up a template sensor." - }, - "migration": { - "title": "The IMAP email content integration needs attention", - "fix_flow": { - "step": { - "start": { - "title": "Migrate your IMAP email configuration", - "description": "The IMAP email content integration is deprecated. Your IMAP server configuration can be migrated automatically to the [imap integration](https://my.home-assistant.io/redirect/config_flow_start?domain=imap), this will enable using a custom `imap` event trigger. To set up a sensor that has an IMAP content state, a template sensor can be used. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml` after migration.\n\nSubmit to start migration of your IMAP server configuration to the `imap` integration." - }, - "confirm": { - "title": "Your IMAP server settings will be migrated", - "description": "In this step an `imap` config entry will be set up with the following configuration:\n\n```text\nServer\t{server}\nPort\t{port}\nUsername\t{username}\nPassword\t*****\nFolder\t{folder}\n```\n\nSee also: (https://www.home-assistant.io/integrations/imap/)\n\nFitering configuration on allowed `sender` is part of the template sensor config that can copied and placed in your `configuration.yaml.\n\nNote that the event filter only filters on the first of the configured allowed senders, customize the filter if needed.\n\n```yaml\n{yaml_example}```\nDo not forget to cleanup the your `configuration.yaml` after migration.\n\nSubmit to migrate your IMAP server configuration to an `imap` configuration entry." - } - }, - "abort": { - "already_configured": "The IMAP server config was already migrated to the imap integration. Remove the `imap_email_plaform` sensor configuration from your `configuration.yaml`.", - "cannot_connect": "Migration failed. Failed to connect to the IMAP server. Perform a manual migration." - } - } - } - } -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 253669edf7d..8a6ff2e354d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2604,12 +2604,6 @@ "config_flow": true, "iot_class": "cloud_push" }, - "imap_email_content": { - "name": "IMAP Email Content", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index efb505cda77..d36cffbce06 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -469,73 +469,6 @@ async def test_advanced_options_form( assert assert_result == data_entry_flow.FlowResultType.FORM -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "folder": "INBOX", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "IMAP" - assert result2["data"] == { - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX", - "search": "UnSeen UnDeleted", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_flow_connection_error(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - with patch( - "homeassistant.components.imap.config_flow.connect_to_server", - side_effect=AioImapException("Unexpected error"), - ), patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "name": "IMAP", - "username": "email@email.com", - "password": "password", - "server": "imap.server.com", - "port": 993, - "folder": "INBOX", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - @pytest.mark.parametrize("cipher_list", ["python_default", "modern", "intermediate"]) @pytest.mark.parametrize("verify_ssl", [False, True]) async def test_config_flow_with_cipherlist_and_ssl_verify( diff --git a/tests/components/imap_email_content/__init__.py b/tests/components/imap_email_content/__init__.py deleted file mode 100644 index 2c7e5692366..00000000000 --- a/tests/components/imap_email_content/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the imap_email_content component.""" diff --git a/tests/components/imap_email_content/test_repairs.py b/tests/components/imap_email_content/test_repairs.py deleted file mode 100644 index 6323dcde377..00000000000 --- a/tests/components/imap_email_content/test_repairs.py +++ /dev/null @@ -1,296 +0,0 @@ -"""Test repairs for imap_email_content.""" - -from collections.abc import Generator -from http import HTTPStatus -from unittest.mock import MagicMock, patch - -import pytest - -from homeassistant.components.repairs.websocket_api import ( - RepairsFlowIndexView, - RepairsFlowResourceView, -) -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator, WebSocketGenerator - - -@pytest.fixture -def mock_client() -> Generator[MagicMock, None, None]: - """Mock the imap client.""" - with patch( - "homeassistant.components.imap_email_content.sensor.EmailReader.read_next", - return_value=None, - ), patch("imaplib.IMAP4_SSL") as mock_imap_client: - yield mock_imap_client - - -CONFIG = { - "platform": "imap_email_content", - "name": "Notifications", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": "{{ body }}", - "senders": ["company@example.com"], -} -DESCRIPTION_PLACEHOLDERS = { - "yaml_example": "" - "template:\n" - "- sensor:\n" - " - name: Notifications\n" - " state: '{{ trigger.event.data[\"text\"] }}'\n" - " trigger:\n - event_data:\n" - " sender: company@example.com\n" - " event_type: imap_content\n" - " id: custom_event\n" - " platform: event\n", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": '{{ trigger.event.data["text"] }}', - "name": "Notifications", -} - -CONFIG_DEFAULT = { - "platform": "imap_email_content", - "name": "Notifications", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "senders": ["company@example.com"], -} -DESCRIPTION_PLACEHOLDERS_DEFAULT = { - "yaml_example": "" - "template:\n" - "- sensor:\n" - " - name: Notifications\n" - " state: '{{ trigger.event.data[\"subject\"] }}'\n" - " trigger:\n - event_data:\n" - " sender: company@example.com\n" - " event_type: imap_content\n" - " id: custom_event\n" - " platform: event\n", - "server": "imap.example.com", - "port": 993, - "username": "john.doe@example.com", - "password": "**SECRET**", - "folder": "INBOX.Notifications", - "value_template": '{{ trigger.event.data["subject"] }}', - "name": "Notifications", -} - - -@pytest.mark.parametrize( - ("config", "description_placeholders"), - [ - (CONFIG, DESCRIPTION_PLACEHOLDERS), - (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), - ], - ids=["with_value_template", "default_subject"], -) -async def test_deprecation_repair_flow( - hass: HomeAssistant, - mock_client: MagicMock, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - config: str | None, - description_placeholders: str, -) -> None: - """Test the deprecation repair flow.""" - # setup config - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - - state = hass.states.get("sensor.notifications") - assert state is not None - - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert issue["is_fixable"] - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "start" - - # Apply fix - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "confirm" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - resp = await client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data["type"] == "create_entry" - - # Assert the issue is resolved - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) == 0 - - -@pytest.mark.parametrize( - ("config", "description_placeholders"), - [ - (CONFIG, DESCRIPTION_PLACEHOLDERS), - (CONFIG_DEFAULT, DESCRIPTION_PLACEHOLDERS_DEFAULT), - ], - ids=["with_value_template", "default_subject"], -) -async def test_repair_flow_where_entry_already_exists( - hass: HomeAssistant, - mock_client: MagicMock, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - config: str | None, - description_placeholders: str, -) -> None: - """Test the deprecation repair flow and an entry already exists.""" - - await async_setup_component(hass, "sensor", {"sensor": config}) - await hass.async_block_till_done() - state = hass.states.get("sensor.notifications") - assert state is not None - - existing_imap_entry_config = { - "username": "john.doe@example.com", - "password": "password", - "server": "imap.example.com", - "port": 993, - "charset": "utf-8", - "folder": "INBOX.Notifications", - "search": "UnSeen UnDeleted", - } - - with patch("homeassistant.components.imap.async_setup_entry", return_value=True): - imap_entry = MockConfigEntry(domain="imap", data=existing_imap_entry_config) - imap_entry.add_to_hass(hass) - await hass.config_entries.async_setup(imap_entry.entry_id) - ws_client = await hass_ws_client(hass) - client = await hass_client() - - await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) - - msg = await ws_client.receive_json() - - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert issue["is_fixable"] - assert issue["translation_key"] == "migration" - - url = RepairsFlowIndexView.url - resp = await client.post( - url, json={"handler": "imap_email_content", "issue_id": issue["issue_id"]} - ) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "start" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url) - assert resp.status == HTTPStatus.OK - data = await resp.json() - - flow_id = data["flow_id"] - assert data["description_placeholders"] == description_placeholders - assert data["step_id"] == "confirm" - - url = RepairsFlowResourceView.url.format(flow_id=flow_id) - - with patch( - "homeassistant.components.imap.config_flow.connect_to_server" - ) as mock_client, patch( - "homeassistant.components.imap.async_setup_entry", - return_value=True, - ): - mock_client.return_value.search.return_value = ( - "OK", - [b""], - ) - resp = await client.post(url) - - assert resp.status == HTTPStatus.OK - data = await resp.json() - - assert data["type"] == "abort" - assert data["reason"] == "already_configured" - - # We should now have a non_fixable issue left since there is still - # a config in configuration.yaml - await ws_client.send_json({"id": 2, "type": "repairs/list_issues"}) - msg = await ws_client.receive_json() - assert msg["success"] - assert len(msg["result"]["issues"]) > 0 - issue = None - for i in msg["result"]["issues"]: - if i["domain"] == "imap_email_content": - issue = i - assert issue is not None - assert ( - issue["issue_id"] - == "Notifications_john.doe@example.com_imap.example.com_INBOX.Notifications" - ) - assert not issue["is_fixable"] - assert issue["translation_key"] == "deprecation" diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py deleted file mode 100644 index 3e8a6c1e282..00000000000 --- a/tests/components/imap_email_content/test_sensor.py +++ /dev/null @@ -1,253 +0,0 @@ -"""The tests for the IMAP email content sensor platform.""" -from collections import deque -import datetime -import email -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText - -from homeassistant.components.imap_email_content import sensor as imap_email_content -from homeassistant.core import HomeAssistant -from homeassistant.helpers.event import async_track_state_change -from homeassistant.helpers.template import Template -from homeassistant.setup import async_setup_component - - -class FakeEMailReader: - """A test class for sending test emails.""" - - def __init__(self, messages) -> None: - """Set up the fake email reader.""" - self._messages = messages - self.last_id = 0 - self.last_unread_id = len(messages) - - def add_test_message(self, message): - """Add a new message.""" - self.last_unread_id += 1 - self._messages.append(message) - - def connect(self): - """Stay always Connected.""" - return True - - def read_next(self): - """Get the next email.""" - if len(self._messages) == 0: - return None - self.last_id += 1 - return self._messages.popleft() - - -async def test_integration_setup_(hass: HomeAssistant) -> None: - """Test the integration component setup is successful.""" - assert await async_setup_component(hass, "imap_email_content", {}) - - -async def test_allowed_sender(hass: HomeAssistant) -> None: - """Test emails from allowed sender.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test" - assert sensor.extra_state_attributes["body"] == "Test Message" - assert sensor.extra_state_attributes["from"] == "sender@test.com" - assert sensor.extra_state_attributes["subject"] == "Test" - assert ( - datetime.datetime(2016, 1, 1, 12, 44, 57) - == sensor.extra_state_attributes["date"] - ) - - -async def test_multi_part_with_text(hass: HomeAssistant) -> None: - """Test multi part emails.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - text = "Test Message" - html = "Test Message" - - textPart = MIMEText(text, "plain") - htmlPart = MIMEText(html, "html") - - msg.attach(textPart) - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multi_part_only_html(hass: HomeAssistant) -> None: - """Test multi part emails with only HTML.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - html = "Test Message" - - htmlPart = MIMEText(html, "html") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert ( - sensor.extra_state_attributes["body"] - == "Test Message" - ) - - -async def test_multi_part_only_other_text(hass: HomeAssistant) -> None: - """Test multi part emails with only other text.""" - msg = MIMEMultipart("alternative") - msg["Subject"] = "Link" - msg["From"] = "sender@test.com" - - other = "Test Message" - - htmlPart = MIMEText(other, "other") - - msg.attach(htmlPart) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([msg])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Link" - assert sensor.extra_state_attributes["body"] == "Test Message" - - -async def test_multiple_emails(hass: HomeAssistant) -> None: - """Test multiple emails, discarding stale states.""" - states = [] - - test_message1 = email.message.Message() - test_message1["From"] = "sender@test.com" - test_message1["Subject"] = "Test" - test_message1["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message1.set_payload("Test Message") - - test_message2 = email.message.Message() - test_message2["From"] = "sender@test.com" - test_message2["Subject"] = "Test 2" - test_message2["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 58) - test_message2.set_payload("Test Message 2") - - test_message3 = email.message.Message() - test_message3["From"] = "sender@test.com" - test_message3["Subject"] = "Test 3" - test_message3["Date"] = datetime.datetime(2016, 1, 1, 12, 50, 1) - test_message3.set_payload("Test Message 2") - - def state_changed_listener(entity_id, from_s, to_s): - states.append(to_s) - - async_track_state_change(hass, ["sensor.emailtest"], state_changed_listener) - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message1, test_message2])), - "test_emails_sensor", - ["sender@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - # Fake a new received message - sensor._email_reader.add_test_message(test_message3) - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - - assert states[0].state == "Test 2" - assert states[1].state == "Test 3" - - assert sensor.extra_state_attributes["body"] == "Test Message 2" - - -async def test_sender_not_allowed(hass: HomeAssistant) -> None: - """Test not whitelisted emails.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["other@test.com"], - None, - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state is None - - -async def test_template(hass: HomeAssistant) -> None: - """Test value template.""" - test_message = email.message.Message() - test_message["From"] = "sender@test.com" - test_message["Subject"] = "Test" - test_message["Date"] = datetime.datetime(2016, 1, 1, 12, 44, 57) - test_message.set_payload("Test Message") - - sensor = imap_email_content.EmailContentSensor( - hass, - FakeEMailReader(deque([test_message])), - "test_emails_sensor", - ["sender@test.com"], - Template("{{ subject }} from {{ from }} with message {{ body }}", hass), - ) - - sensor.entity_id = "sensor.emailtest" - sensor.async_schedule_update_ha_state(True) - await hass.async_block_till_done() - assert sensor.state == "Test from sender@test.com with message Test Message" From b33d5fece6d91e4ce43fdde6f5b923f8e37810a1 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Oct 2023 08:59:06 +0200 Subject: [PATCH 101/968] Remove platform YAML from Snapcast (#101225) --- .../components/snapcast/config_flow.py | 10 ---- .../components/snapcast/media_player.py | 48 ++----------------- tests/components/snapcast/test_config_flow.py | 15 ------ 3 files changed, 3 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/snapcast/config_flow.py b/homeassistant/components/snapcast/config_flow.py index 896d3f8b5a8..479d1d648b8 100644 --- a/homeassistant/components/snapcast/config_flow.py +++ b/homeassistant/components/snapcast/config_flow.py @@ -51,13 +51,3 @@ class SnapcastConfigFlow(ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=SNAPCAST_SCHEMA, errors=errors ) - - async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match( - { - CONF_HOST: (import_config[CONF_HOST]), - CONF_PORT: (import_config[CONF_PORT]), - } - ) - return self.async_create_entry(title=DEFAULT_TITLE, data=import_config) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index f0b6eccf8b4..ae2917a106d 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -1,24 +1,19 @@ """Support for interacting with Snapcast clients.""" from __future__ import annotations -import logging - -from snapcast.control.server import CONTROL_PORT, Snapserver +from snapcast.control.server import Snapserver import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( ATTR_LATENCY, @@ -35,12 +30,6 @@ from .const import ( SERVICE_UNJOIN, ) -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT): cv.port} -) - STREAM_STATUS = { "idle": MediaPlayerState.IDLE, "playing": MediaPlayerState.PLAYING, @@ -93,37 +82,6 @@ async def async_setup_entry( ].hass_async_add_entities = async_add_entities -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Snapcast platform.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Snapcast", - }, - ) - - config[CONF_PORT] = config.get(CONF_PORT, CONTROL_PORT) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - - async def handle_async_join(entity, service_call): """Handle the entity service join.""" if not isinstance(entity, SnapcastClientDevice): diff --git a/tests/components/snapcast/test_config_flow.py b/tests/components/snapcast/test_config_flow.py index b6ff43503a6..bb07eae2140 100644 --- a/tests/components/snapcast/test_config_flow.py +++ b/tests/components/snapcast/test_config_flow.py @@ -93,18 +93,3 @@ async def test_abort( assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import(hass: HomeAssistant) -> None: - """Test successful import.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=TEST_CONNECTION, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Snapcast" - assert result["data"] == {CONF_HOST: "snapserver.test", CONF_PORT: 1705} From b56d25121ff982e129c3d95e1ca8fd8f1df0be65 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Oct 2023 09:00:09 +0200 Subject: [PATCH 102/968] Remove platform YAML qBittorrent (#101224) --- .../components/qbittorrent/config_flow.py | 21 +------ .../components/qbittorrent/sensor.py | 57 ++----------------- .../qbittorrent/test_config_flow.py | 32 +---------- 3 files changed, 6 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/qbittorrent/config_flow.py b/homeassistant/components/qbittorrent/config_flow.py index 54c47c53895..54215fb4563 100644 --- a/homeassistant/components/qbittorrent/config_flow.py +++ b/homeassistant/components/qbittorrent/config_flow.py @@ -9,13 +9,7 @@ from requests.exceptions import RequestException import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_URL, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_NAME, DEFAULT_URL, DOMAIN @@ -61,16 +55,3 @@ class QbittorrentConfigFlow(ConfigFlow, domain=DOMAIN): schema = self.add_suggested_values_to_schema(USER_DATA_SCHEMA, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match({CONF_URL: config[CONF_URL]}) - return self.async_create_entry( - title=config.get(CONF_NAME, DEFAULT_NAME), - data={ - CONF_URL: config[CONF_URL], - CONF_USERNAME: config[CONF_USERNAME], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_VERIFY_SSL: True, - }, - ) diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 0d5dc160a11..5cca77ecc34 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -5,31 +5,19 @@ import logging from qbittorrent.client import Client, LoginRequired from requests.exceptions import RequestException -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - CONF_URL, - CONF_USERNAME, - STATE_IDLE, - UnitOfDataRate, -) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_IDLE, UnitOfDataRate +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DEFAULT_NAME, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -60,43 +48,6 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), ) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the qBittorrent platform.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config - ) - ) - ir.async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "qBittorrent", - }, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/tests/components/qbittorrent/test_config_flow.py b/tests/components/qbittorrent/test_config_flow.py index b7244ccef8d..4131b9142e2 100644 --- a/tests/components/qbittorrent/test_config_flow.py +++ b/tests/components/qbittorrent/test_config_flow.py @@ -4,7 +4,7 @@ from requests.exceptions import RequestException import requests_mock from homeassistant.components.qbittorrent.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_PASSWORD, CONF_SOURCE, @@ -104,33 +104,3 @@ async def test_flow_user_already_configured(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_flow_import(hass: HomeAssistant) -> None: - """Test import step.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_IMPORT}, - data=YAML_IMPORT, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == { - CONF_URL: "http://localhost:8080", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - CONF_VERIFY_SSL: True, - } - - -async def test_flow_import_already_configured(hass: HomeAssistant) -> None: - """Test import step already configured.""" - entry = MockConfigEntry(domain=DOMAIN, data=USER_INPUT) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={CONF_SOURCE: SOURCE_IMPORT}, - data=YAML_IMPORT, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" From f2cf87b0b7e4d2915c6d33c7897c212770591443 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Oct 2023 09:01:03 +0200 Subject: [PATCH 103/968] Remove YAML import from Workday (#101223) --- .../components/workday/binary_sensor.py | 89 +---------- .../components/workday/config_flow.py | 29 ---- homeassistant/components/workday/strings.json | 1 - .../components/workday/test_binary_sensor.py | 46 ------ tests/components/workday/test_config_flow.py | 139 ------------------ 5 files changed, 3 insertions(+), 301 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 5daea6ce129..6a541cc84e1 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,28 +2,19 @@ from __future__ import annotations from datetime import date, timedelta -from typing import Any from holidays import ( HolidayBase, __version__ as python_holidays_version, country_holidays, - list_supported_countries, ) -import voluptuous as vol -from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - BinarySensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util from .const import ( @@ -35,10 +26,6 @@ from .const import ( CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, - DEFAULT_EXCLUDES, - DEFAULT_NAME, - DEFAULT_OFFSET, - DEFAULT_WORKDAYS, DOMAIN, LOGGER, ) @@ -64,76 +51,6 @@ def validate_dates(holiday_list: list[str]) -> list[str]: return calc_holidays -def valid_country(value: Any) -> str: - """Validate that the given country is supported.""" - value = cv.string(value) - - try: - raw_value = value.encode("utf-8") - except UnicodeError as err: - raise vol.Invalid( - "The country name or the abbreviation must be a valid UTF-8 string." - ) from err - if not raw_value: - raise vol.Invalid("Country name or the abbreviation must not be empty.") - if value not in list_supported_countries(): - raise vol.Invalid("Country is not supported.") - return value - - -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COUNTRY): valid_country, - vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int), - vol.Optional(CONF_PROVINCE): cv.string, - vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All( - cv.ensure_list, [vol.In(ALLOWED_DAYS)] - ), - vol.Optional(CONF_ADD_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - vol.Optional(CONF_REMOVE_HOLIDAYS, default=[]): vol.All( - cv.ensure_list, [cv.string] - ), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Workday sensor.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Workday", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 6be7e119876..6b2ecf2298a 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -179,33 +179,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return WorkdayOptionsFlowHandler(config_entry) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - - abort_match = { - CONF_COUNTRY: config[CONF_COUNTRY], - CONF_EXCLUDES: config[CONF_EXCLUDES], - CONF_OFFSET: config[CONF_OFFSET], - CONF_WORKDAYS: config[CONF_WORKDAYS], - CONF_ADD_HOLIDAYS: config[CONF_ADD_HOLIDAYS], - CONF_REMOVE_HOLIDAYS: config[CONF_REMOVE_HOLIDAYS], - CONF_PROVINCE: config.get(CONF_PROVINCE), - } - new_config = config.copy() - new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) - LOGGER.debug("Importing with %s", new_config) - - self._async_abort_entries_match(abort_match) - - self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) - self.data[CONF_COUNTRY] = config[CONF_COUNTRY] - LOGGER.debug( - "No duplicate, next step with name %s for country %s", - self.data[CONF_NAME], - self.data[CONF_COUNTRY], - ) - return await self.async_step_options(user_input=new_config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -246,8 +219,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors["remove_holidays"] = "remove_holiday_error" except RemoveDateRangeError: errors["remove_holidays"] = "remove_holiday_range_error" - except NotImplementedError: - self.async_abort(reason="incorrect_province") abort_match = { CONF_COUNTRY: combined_input[CONF_COUNTRY], diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index a4c2baf31c8..0249b580b60 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,7 +2,6 @@ "title": "Workday", "config": { "abort": { - "incorrect_province": "Incorrect subdivision from yaml import", "already_configured": "Workday has already been setup with chosen configuration" }, "step": { diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 5c387e9a179..eeeb765e4a8 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -4,9 +4,7 @@ from typing import Any from freezegun.api import FrozenDateTimeFactory import pytest -import voluptuous as vol -from homeassistant.components.workday import binary_sensor from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.dt import UTC @@ -38,21 +36,6 @@ from . import ( ) -async def test_valid_country_yaml() -> None: - """Test valid country from yaml.""" - # Invalid UTF-8, must not contain U+D800 to U+DFFF - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\ud800") - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("\udfff") - # Country MUST NOT be empty - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("") - # Country must be supported by holidays - with pytest.raises(vol.Invalid): - binary_sensor.valid_country("HomeAssistantLand") - - @pytest.mark.parametrize( ("config", "expected_state"), [ @@ -89,35 +72,6 @@ async def test_setup( } -async def test_setup_from_import( - hass: HomeAssistant, - freezer: FrozenDateTimeFactory, -) -> None: - """Test setup from various configs.""" - freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday - await async_setup_component( - hass, - "binary_sensor", - { - "binary_sensor": { - "platform": "workday", - "country": "DE", - } - }, - ) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.workday_sensor") - assert state is not None - assert state.state == "off" - assert state.attributes == { - "friendly_name": "Workday Sensor", - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - } - - async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" await async_setup_component( diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index 65e6c70fa00..cdc5f2a4011 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -13,7 +13,6 @@ from homeassistant.components.workday.const import ( CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, - DEFAULT_NAME, DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, @@ -24,8 +23,6 @@ from homeassistant.data_entry_flow import FlowResultType from . import init_integration -from tests.common import MockConfigEntry - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -156,142 +153,6 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: } -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: DEFAULT_NAME, - CONF_COUNTRY: "DE", - CONF_EXCLUDES: DEFAULT_EXCLUDES, - CONF_OFFSET: DEFAULT_OFFSET, - CONF_WORKDAYS: DEFAULT_WORKDAYS, - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Workday Sensor" - assert result["options"] == { - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - "province": None, - } - - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday Sensor 2", - CONF_COUNTRY: "DE", - CONF_PROVINCE: "BW", - CONF_EXCLUDES: DEFAULT_EXCLUDES, - CONF_OFFSET: DEFAULT_OFFSET, - CONF_WORKDAYS: DEFAULT_WORKDAYS, - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Workday Sensor 2" - assert result2["options"] == { - "name": "Workday Sensor 2", - "country": "DE", - "province": "BW", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - } - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - "province": None, - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday sensor 2", - CONF_COUNTRY: "DE", - CONF_EXCLUDES: ["sat", "sun", "holiday"], - CONF_OFFSET: 0, - CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_flow_province_no_conflict(hass: HomeAssistant) -> None: - """Test import of yaml with province.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "name": "Workday Sensor", - "country": "DE", - "excludes": ["sat", "sun", "holiday"], - "days_offset": 0, - "workdays": ["mon", "tue", "wed", "thu", "fri"], - "add_holidays": [], - "remove_holidays": [], - }, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_NAME: "Workday sensor 2", - CONF_COUNTRY: "DE", - CONF_PROVINCE: "BW", - CONF_EXCLUDES: ["sat", "sun", "holiday"], - CONF_OFFSET: 0, - CONF_WORKDAYS: ["mon", "tue", "wed", "thu", "fri"], - CONF_ADD_HOLIDAYS: [], - CONF_REMOVE_HOLIDAYS: [], - }, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - - async def test_options_form(hass: HomeAssistant) -> None: """Test we get the form in options.""" From 99a76ef4e6d6ca463ccad564ff6c79118cab8178 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 2 Oct 2023 08:01:41 +0100 Subject: [PATCH 104/968] Fix most sphinx documentation warnings (#101228) --- docs/source/api/data_entry_flow.rst | 2 +- docs/source/api/helpers.rst | 8 -------- docs/source/conf.py | 2 +- homeassistant/helpers/config_entry_oauth2_flow.py | 3 ++- homeassistant/helpers/json.py | 4 ++-- 5 files changed, 6 insertions(+), 13 deletions(-) diff --git a/docs/source/api/data_entry_flow.rst b/docs/source/api/data_entry_flow.rst index 7252780b870..0159dd51c5a 100644 --- a/docs/source/api/data_entry_flow.rst +++ b/docs/source/api/data_entry_flow.rst @@ -1,7 +1,7 @@ .. _data_entry_flow_module: :mod:`homeassistant.data_entry_flow` ------------------------------ +------------------------------------ .. automodule:: homeassistant.data_entry_flow :members: diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst index 1b0b529c655..753771ebc83 100644 --- a/docs/source/api/helpers.rst +++ b/docs/source/api/helpers.rst @@ -214,14 +214,6 @@ homeassistant.helpers.location :undoc-members: :show-inheritance: -homeassistant.helpers.logging ------------------------------ - -.. automodule:: homeassistant.helpers.logging - :members: - :undoc-members: - :show-inheritance: - homeassistant.helpers.network ----------------------------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 302a0655544..3bd3baa39cc 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -139,7 +139,7 @@ def linkcode_resolve(domain, info): # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the command line for these cases. -language = None +language = "en" # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 4fd8948843e..6538c7fe891 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -72,7 +72,8 @@ class AbstractOAuth2Implementation(ABC): Pass external data in with: await hass.config_entries.flow.async_configure( - flow_id=flow_id, user_input={'code': 'abcd', 'state': { … } + flow_id=flow_id, user_input={'code': 'abcd', 'state': … } + ) """ diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index e94093cfd2f..e155427fa10 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -115,7 +115,7 @@ def json_bytes_strip_null(data: Any) -> bytes: def json_dumps(data: Any) -> str: - """Dump json string. + r"""Dump json string. orjson supports serializing dataclasses natively which eliminates the need to implement as_dict in many places @@ -124,7 +124,7 @@ def json_dumps(data: Any) -> str: be serialized. If it turns out to be a problem we can disable this - with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it + with option \|= orjson.OPT_PASSTHROUGH_DATACLASS and it will fallback to as_dict """ return json_bytes(data).decode("utf-8") From 6ce6952a06d5da60cafb32acf816816e1b580c76 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Mon, 2 Oct 2023 21:35:15 +1300 Subject: [PATCH 105/968] ESPHome: fix voice assistant default audio settings (#101241) --- homeassistant/components/esphome/voice_assistant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index baf3a9011e9..dc36b7475c4 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -222,7 +222,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): audio_settings: VoiceAssistantAudioSettings | None = None, ) -> None: """Run the Voice Assistant pipeline.""" - if audio_settings is None: + if audio_settings is None or audio_settings.volume_multiplier == 0: audio_settings = VoiceAssistantAudioSettings() tts_audio_output = ( From e652d37f2944528a289eaadd9cbe18fd7019cd06 Mon Sep 17 00:00:00 2001 From: Ian Date: Mon, 2 Oct 2023 01:56:10 -0700 Subject: [PATCH 106/968] Use data update coordinator in NextBus to reduce api calls (#100602) --- homeassistant/components/nextbus/__init__.py | 28 ++++- .../components/nextbus/coordinator.py | 78 +++++++++++++ .../components/nextbus/manifest.json | 2 +- homeassistant/components/nextbus/sensor.py | 104 ++++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nextbus/test_sensor.py | 38 ++++++- 7 files changed, 200 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/nextbus/coordinator.py diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index b582f82b929..e1f4dcc2840 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -4,15 +4,41 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .coordinator import NextBusDataUpdateCoordinator + PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platforms for NextBus.""" + entry_agency = entry.data[CONF_AGENCY] + + coordinator: NextBusDataUpdateCoordinator = hass.data.setdefault(DOMAIN, {}).get( + entry_agency + ) + if coordinator is None: + coordinator = NextBusDataUpdateCoordinator(hass, entry_agency) + hass.data[DOMAIN][entry_agency] = coordinator + + coordinator.add_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_agency = entry.data.get(CONF_AGENCY) + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][entry_agency] + coordinator.remove_stop_route(entry.data[CONF_STOP], entry.data[CONF_ROUTE]) + if not coordinator.has_routes(): + hass.data[DOMAIN].pop(entry_agency) + + return True + + return False diff --git a/homeassistant/components/nextbus/coordinator.py b/homeassistant/components/nextbus/coordinator.py new file mode 100644 index 00000000000..f130e40ef05 --- /dev/null +++ b/homeassistant/components/nextbus/coordinator.py @@ -0,0 +1,78 @@ +"""NextBus data update coordinator.""" +from datetime import timedelta +import logging +from typing import Any, cast + +from py_nextbus import NextBusClient +from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN +from .util import listify + +_LOGGER = logging.getLogger(__name__) + + +class NextBusDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching NextBus data.""" + + def __init__(self, hass: HomeAssistant, agency: str) -> None: + """Initialize a global coordinator for fetching data for a given agency.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.client = NextBusClient(output_format="json", agency=agency) + self._agency = agency + self._stop_routes: set[RouteStop] = set() + self._predictions: dict[RouteStop, dict[str, Any]] = {} + + def add_stop_route(self, stop_tag: str, route_tag: str) -> None: + """Tell coordinator to start tracking a given stop and route.""" + self._stop_routes.add(RouteStop(route_tag, stop_tag)) + + def remove_stop_route(self, stop_tag: str, route_tag: str) -> None: + """Tell coordinator to stop tracking a given stop and route.""" + self._stop_routes.remove(RouteStop(route_tag, stop_tag)) + + def get_prediction_data( + self, stop_tag: str, route_tag: str + ) -> dict[str, Any] | None: + """Get prediction result for a given stop and route.""" + return self._predictions.get(RouteStop(route_tag, stop_tag)) + + def _calc_predictions(self, data: dict[str, Any]) -> None: + self._predictions = { + RouteStop(prediction["routeTag"], prediction["stopTag"]): prediction + for prediction in listify(data.get("predictions", [])) + } + + def get_attribution(self) -> str | None: + """Get attribution from api results.""" + return self.data.get("copyright") + + def has_routes(self) -> bool: + """Check if this coordinator is tracking any routes.""" + return len(self._stop_routes) > 0 + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from NextBus.""" + self.logger.debug("Updating data from API. Routes: %s", str(self._stop_routes)) + + def _update_data() -> dict: + """Fetch data from NextBus.""" + self.logger.debug("Updating data from API (executor)") + try: + data = self.client.get_predictions_for_multi_stops(self._stop_routes) + # Casting here because we expect dict and not a str due to the input format selected being JSON + data = cast(dict[str, Any], data) + self._calc_predictions(data) + return data + except (NextBusHTTPError, NextBusFormatError) as ex: + raise UpdateFailed("Failed updating nextbus data", ex) from ex + + return await self.hass.async_add_executor_job(_update_data) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 15eb9b4e245..9d1490a4ae6 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], - "requirements": ["py-nextbusnext==0.1.5"] + "requirements": ["py-nextbusnext==1.0.0"] } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 1582ec25ffe..6ef647f98ad 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -3,8 +3,8 @@ from __future__ import annotations from itertools import chain import logging +from typing import cast -from py_nextbus import NextBusClient import voluptuous as vol from homeassistant.components.sensor import ( @@ -14,14 +14,16 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import utc_from_timestamp from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN +from .coordinator import NextBusDataUpdateCoordinator from .util import listify, maybe_first _LOGGER = logging.getLogger(__name__) @@ -70,23 +72,28 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" - client = NextBusClient(output_format="json") - _LOGGER.debug(config.data) + entry_agency = config.data[CONF_AGENCY] - sensor = NextBusDepartureSensor( - client, - config.unique_id, - config.data[CONF_AGENCY], - config.data[CONF_ROUTE], - config.data[CONF_STOP], - config.data.get(CONF_NAME) or config.title, + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN].get(entry_agency) + + async_add_entities( + ( + NextBusDepartureSensor( + coordinator, + cast(str, config.unique_id), + config.data[CONF_AGENCY], + config.data[CONF_ROUTE], + config.data[CONF_STOP], + config.data.get(CONF_NAME) or config.title, + ), + ), ) - async_add_entities((sensor,), True) - -class NextBusDepartureSensor(SensorEntity): +class NextBusDepartureSensor( + CoordinatorEntity[NextBusDataUpdateCoordinator], SensorEntity +): """Sensor class that displays upcoming NextBus times. To function, this requires knowing the agency tag as well as the tags for @@ -100,49 +107,57 @@ class NextBusDepartureSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, client, unique_id, agency, route, stop, name): + def __init__( + self, + coordinator: NextBusDataUpdateCoordinator, + unique_id: str, + agency: str, + route: str, + stop: str, + name: str, + ) -> None: """Initialize sensor with all required config.""" + super().__init__(coordinator) self.agency = agency self.route = route self.stop = stop - self._attr_extra_state_attributes = {} + self._attr_extra_state_attributes: dict[str, str] = {} self._attr_unique_id = unique_id self._attr_name = name - self._client = client - def _log_debug(self, message, *args): """Log debug message with prefix.""" _LOGGER.debug(":".join((self.agency, self.route, self.stop, message)), *args) - def update(self) -> None: + def _log_err(self, message, *args): + """Log error message with prefix.""" + _LOGGER.error(":".join((self.agency, self.route, self.stop, message)), *args) + + async def async_added_to_hass(self) -> None: + """Read data from coordinator after adding to hass.""" + self._handle_coordinator_update() + await super().async_added_to_hass() + + @callback + def _handle_coordinator_update(self) -> None: """Update sensor with new departures times.""" - # Note: using Multi because there is a bug with the single stop impl - results = self._client.get_predictions_for_multi_stops( - [{"stop_tag": self.stop, "route_tag": self.route}], self.agency - ) + results = self.coordinator.get_prediction_data(self.stop, self.route) + self._attr_attribution = self.coordinator.get_attribution() self._log_debug("Predictions results: %s", results) - self._attr_attribution = results.get("copyright") - if "Error" in results: - self._log_debug("Could not get predictions: %s", results) - - if not results.get("predictions"): - self._log_debug("No predictions available") + if not results or "Error" in results: + self._log_err("Error getting predictions: %s", str(results)) self._attr_native_value = None - # Remove attributes that may now be outdated self._attr_extra_state_attributes.pop("upcoming", None) return - results = results["predictions"] - # Set detailed attributes self._attr_extra_state_attributes.update( { - "agency": results.get("agencyTitle"), - "route": results.get("routeTitle"), - "stop": results.get("stopTitle"), + "agency": str(results.get("agencyTitle")), + "route": str(results.get("routeTitle")), + "stop": str(results.get("stopTitle")), } ) @@ -171,14 +186,15 @@ class NextBusDepartureSensor(SensorEntity): self._log_debug("No upcoming predictions available") self._attr_native_value = None self._attr_extra_state_attributes["upcoming"] = "No upcoming predictions" - return + else: + # Generate list of upcoming times + self._attr_extra_state_attributes["upcoming"] = ", ".join( + sorted((p["minutes"] for p in predictions), key=int) + ) - # Generate list of upcoming times - self._attr_extra_state_attributes["upcoming"] = ", ".join( - sorted((p["minutes"] for p in predictions), key=int) - ) + latest_prediction = maybe_first(predictions) + self._attr_native_value = utc_from_timestamp( + int(latest_prediction["epochTime"]) / 1000 + ) - latest_prediction = maybe_first(predictions) - self._attr_native_value = utc_from_timestamp( - int(latest_prediction["epochTime"]) / 1000 - ) + self.async_write_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index c5b3316072b..f89f428beaa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1508,7 +1508,7 @@ py-dormakaba-dkey==1.0.5 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==0.1.5 +py-nextbusnext==1.0.0 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3338def76f7..03c4981188d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1153,7 +1153,7 @@ py-dormakaba-dkey==1.0.5 py-melissa-climate==2.1.4 # homeassistant.components.nextbus -py-nextbusnext==0.1.5 +py-nextbusnext==1.0.0 # homeassistant.components.nightscout py-nightscout==1.2.2 diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 071dd95fe7b..a4d04997e15 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -2,7 +2,9 @@ from collections.abc import Generator from copy import deepcopy from unittest.mock import MagicMock, patch +from urllib.error import HTTPError +from py_nextbus.client import NextBusFormatError, NextBusHTTPError, RouteStop import pytest from homeassistant.components import sensor @@ -12,10 +14,12 @@ from homeassistant.components.nextbus.const import ( CONF_STOP, DOMAIN, ) +from homeassistant.components.nextbus.coordinator import NextBusDataUpdateCoordinator from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_NAME from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers import entity_registry as er, issue_registry as ir +from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -70,9 +74,7 @@ BASIC_RESULTS = { @pytest.fixture def mock_nextbus() -> Generator[MagicMock, None, None]: """Create a mock py_nextbus module.""" - with patch( - "homeassistant.components.nextbus.sensor.NextBusClient", - ) as client: + with patch("homeassistant.components.nextbus.coordinator.NextBusClient") as client: yield client @@ -89,7 +91,7 @@ def mock_nextbus_predictions( async def assert_setup_sensor( hass: HomeAssistant, - config: dict[str, str], + config: dict[str, dict[str, str]], expected_state=ConfigEntryState.LOADED, ) -> MockConfigEntry: """Set up the sensor and assert it's been created.""" @@ -144,9 +146,11 @@ async def test_verify_valid_state( ) -> None: """Verify all attributes are set from a valid response.""" await assert_setup_sensor(hass, CONFIG_BASIC) + entity = er.async_get(hass).async_get(SENSOR_ID) + assert entity mock_nextbus_predictions.assert_called_once_with( - [{"stop_tag": VALID_STOP, "route_tag": VALID_ROUTE}], VALID_AGENCY + {RouteStop(VALID_ROUTE, VALID_STOP)} ) state = hass.states.get(SENSOR_ID) @@ -272,6 +276,28 @@ async def test_direction_list( assert state.attributes["upcoming"] == "0, 1, 2, 3" +@pytest.mark.parametrize( + "client_exception", + ( + NextBusHTTPError("failed", HTTPError("url", 500, "error", MagicMock(), None)), + NextBusFormatError("failed"), + ), +) +async def test_prediction_exceptions( + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, + client_exception: Exception, +) -> None: + """Test that some coodinator exceptions raise UpdateFailed exceptions.""" + await assert_setup_sensor(hass, CONFIG_BASIC) + coordinator: NextBusDataUpdateCoordinator = hass.data[DOMAIN][VALID_AGENCY] + mock_nextbus_predictions.side_effect = client_exception + with pytest.raises(UpdateFailed): + await coordinator._async_update_data() + + async def test_custom_name( hass: HomeAssistant, mock_nextbus: MagicMock, From 56e7e20904a77a14336d64354e892d9d09ad9864 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 2 Oct 2023 11:00:41 +0200 Subject: [PATCH 107/968] Remove YAML import from Brottsplatskartan (#101222) --- .../brottsplatskartan/config_flow.py | 15 --- .../components/brottsplatskartan/sensor.py | 58 +--------- .../brottsplatskartan/test_config_flow.py | 108 ------------------ 3 files changed, 5 insertions(+), 176 deletions(-) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index 1de24ffa76c..09d6cd96087 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -34,21 +34,6 @@ class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a configuration from config.yaml.""" - - if config.get(CONF_LATITUDE): - config[CONF_LOCATION] = { - CONF_LATITUDE: config[CONF_LATITUDE], - CONF_LONGITUDE: config[CONF_LONGITUDE], - } - if not config.get(CONF_AREA): - config[CONF_AREA] = "none" - else: - config[CONF_AREA] = config[CONF_AREA][0] - - return await self.async_step_user(user_input=config) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 7d24ebd50b7..df17832f695 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -5,66 +5,18 @@ from collections import defaultdict from datetime import timedelta from brottsplatskartan import ATTRIBUTION, BrottsplatsKartan -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import SensorEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import AREAS, CONF_APP_ID, CONF_AREA, DEFAULT_NAME, DOMAIN, LOGGER +from .const import CONF_APP_ID, CONF_AREA, DOMAIN, LOGGER SCAN_INTERVAL = timedelta(minutes=30) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( - { - vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_AREA, default=[]): vol.All(cv.ensure_list, [vol.In(AREAS)]), - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Brottsplatskartan platform.""" - - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2023.11.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Brottsplatskartan", - }, - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback diff --git a/tests/components/brottsplatskartan/test_config_flow.py b/tests/components/brottsplatskartan/test_config_flow.py index dd3139dc2b9..efd259fa73c 100644 --- a/tests/components/brottsplatskartan/test_config_flow.py +++ b/tests/components/brottsplatskartan/test_config_flow.py @@ -1,8 +1,6 @@ """Test the Brottsplatskartan config flow.""" from __future__ import annotations -from unittest.mock import patch - import pytest from homeassistant import config_entries @@ -11,8 +9,6 @@ from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -103,107 +99,3 @@ async def test_form_area(hass: HomeAssistant) -> None: "area": "Stockholms län", "app_id": "ha-1234567890", } - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan HOME" - assert result2["data"] == { - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "area": None, - "app_id": "ha-1234567890", - } - - -async def test_import_flow_location_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml with location.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 59.32, - CONF_LONGITUDE: 18.06, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan 59.32, 18.06" - assert result2["data"] == { - "latitude": 59.32, - "longitude": 18.06, - "area": None, - "app_id": "ha-1234567890", - } - - -async def test_import_flow_location_area_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml with location and area.""" - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_LATITUDE: 59.32, - CONF_LONGITUDE: 18.06, - CONF_AREA: ["Blekinge län"], - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Brottsplatskartan Blekinge län" - assert result2["data"] == { - "latitude": None, - "longitude": None, - "area": "Blekinge län", - "app_id": "ha-1234567890", - } - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - MockConfigEntry( - domain=DOMAIN, - data={ - "latitude": hass.config.latitude, - "longitude": hass.config.longitude, - "area": None, - "app_id": "ha-1234567890", - }, - unique_id="bpk-home", - ).add_to_hass(hass) - - with patch( - "homeassistant.components.brottsplatskartan.sensor.BrottsplatsKartan", - ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - await hass.async_block_till_done() - - assert result3["type"] == FlowResultType.ABORT - assert result3["reason"] == "already_configured" From 1b43d79717067437dff94dd79aa40ec2bb9ae2bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 11:41:06 +0200 Subject: [PATCH 108/968] Use async_at_started in Netatmo (#100996) * Use async_at_started in Netatmo * Make nice --- homeassistant/components/netatmo/__init__.py | 21 ++++++-------------- 1 file changed, 6 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index f575f227753..11cf167b85c 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,10 +1,10 @@ """The Netatmo integration.""" from __future__ import annotations -from datetime import datetime from http import HTTPStatus import logging import secrets +from typing import Any import aiohttp import pyatmo @@ -26,16 +26,9 @@ from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import ( - DOMAIN as HOMEASSISTANT_DOMAIN, - CoreState, - Event, - HomeAssistant, - ServiceCall, -) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( aiohttp_client, @@ -45,6 +38,7 @@ from homeassistant.helpers import ( from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from . import api @@ -185,7 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await data_handler.async_setup() async def unregister_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: if CONF_WEBHOOK_ID not in entry.data: return @@ -204,7 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) async def register_webhook( - call_or_event_or_dt: ServiceCall | Event | datetime | None, + _: Any, ) -> None: if CONF_WEBHOOK_ID not in entry.data: data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} @@ -254,11 +248,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if cloud.async_is_connected(hass): await register_webhook(None) cloud.async_listen_connection_change(hass, manage_cloudhook) - - elif hass.state == CoreState.running: - await register_webhook(None) else: - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, register_webhook) + async_at_started(hass, register_webhook) hass.services.async_register(DOMAIN, "register_webhook", register_webhook) hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) From 41cb8526d104fc37e5bd00e1b538254494ea0d47 Mon Sep 17 00:00:00 2001 From: Zehuan Li <22104836+zehuanli@users.noreply.github.com> Date: Mon, 2 Oct 2023 05:44:15 -0400 Subject: [PATCH 109/968] Add secret_token support to telegram_bot component (#100869) * Support secret_token for setWebHook api * Revert configuration YAML changes; generate and store secret token instead * Reformat codes * Revert storage of secret token; use ephemeral secret token instead * Reformat * Update homeassistant/components/telegram_bot/webhooks.py * Fix when header is not present * Check for non-empty token * Fix tests to support secret token * Add tests for invalid secret token * Minor: remove comment * Revert back to 401 * ... and for tests * Change patching method for the generation of secret tokens --------- Co-authored-by: Erik Montnemery --- .../components/telegram_bot/webhooks.py | 34 ++++++++-- tests/components/telegram_bot/conftest.py | 21 ++++++- .../telegram_bot/test_telegram_bot.py | 62 +++++++++++++++++-- 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py index 8b94cb66496..c21cffa84b1 100644 --- a/homeassistant/components/telegram_bot/webhooks.py +++ b/homeassistant/components/telegram_bot/webhooks.py @@ -3,6 +3,8 @@ import datetime as dt from http import HTTPStatus from ipaddress import ip_address import logging +import secrets +import string from telegram import Update from telegram.error import TimedOut @@ -18,11 +20,17 @@ _LOGGER = logging.getLogger(__name__) TELEGRAM_WEBHOOK_URL = "/api/telegram_webhooks" REMOVE_WEBHOOK_URL = "" +SECRET_TOKEN_LENGTH = 32 async def async_setup_platform(hass, bot, config): """Set up the Telegram webhooks platform.""" - pushbot = PushBot(hass, bot, config) + + # Generate an ephemeral secret token + alphabet = string.ascii_letters + string.digits + "-_" + secret_token = "".join(secrets.choice(alphabet) for _ in range(SECRET_TOKEN_LENGTH)) + + pushbot = PushBot(hass, bot, config, secret_token) if not pushbot.webhook_url.startswith("https"): _LOGGER.error("Invalid telegram webhook %s must be https", pushbot.webhook_url) @@ -34,7 +42,13 @@ async def async_setup_platform(hass, bot, config): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, pushbot.deregister_webhook) hass.http.register_view( - PushBotView(hass, bot, pushbot.dispatcher, config[CONF_TRUSTED_NETWORKS]) + PushBotView( + hass, + bot, + pushbot.dispatcher, + config[CONF_TRUSTED_NETWORKS], + secret_token, + ) ) return True @@ -42,10 +56,11 @@ async def async_setup_platform(hass, bot, config): class PushBot(BaseTelegramBotEntity): """Handles all the push/webhook logic and passes telegram updates to `self.handle_update`.""" - def __init__(self, hass, bot, config): + def __init__(self, hass, bot, config, secret_token): """Create Dispatcher before calling super().""" self.bot = bot self.trusted_networks = config[CONF_TRUSTED_NETWORKS] + self.secret_token = secret_token # Dumb dispatcher that just gets our updates to our handler callback (self.handle_update) self.dispatcher = Dispatcher(bot, None) self.dispatcher.add_handler(TypeHandler(Update, self.handle_update)) @@ -61,7 +76,11 @@ class PushBot(BaseTelegramBotEntity): retry_num = 0 while retry_num < 3: try: - return self.bot.set_webhook(self.webhook_url, timeout=5) + return self.bot.set_webhook( + self.webhook_url, + api_kwargs={"secret_token": self.secret_token}, + timeout=5, + ) except TimedOut: retry_num += 1 _LOGGER.warning("Timeout trying to set webhook (retry #%d)", retry_num) @@ -108,12 +127,13 @@ class PushBotView(HomeAssistantView): url = TELEGRAM_WEBHOOK_URL name = "telegram_webhooks" - def __init__(self, hass, bot, dispatcher, trusted_networks): + def __init__(self, hass, bot, dispatcher, trusted_networks, secret_token): """Initialize by storing stuff needed for setting up our webhook endpoint.""" self.hass = hass self.bot = bot self.dispatcher = dispatcher self.trusted_networks = trusted_networks + self.secret_token = secret_token async def post(self, request): """Accept the POST from telegram.""" @@ -121,6 +141,10 @@ class PushBotView(HomeAssistantView): if not any(real_ip in net for net in self.trusted_networks): _LOGGER.warning("Access denied from %s", real_ip) return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) + secret_token_header = request.headers.get("X-Telegram-Bot-Api-Secret-Token") + if secret_token_header is None or self.secret_token != secret_token_header: + _LOGGER.warning("Invalid secret token from %s", real_ip) + return self.json_message("Access denied", HTTPStatus.UNAUTHORIZED) try: update_data = await request.json() diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index d8d445fbb86..af23efc1afc 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -65,6 +65,23 @@ def mock_register_webhook(): yield +@pytest.fixture +def mock_generate_secret_token(): + """Mock secret token generated for webhook.""" + mock_secret_token = "DEADBEEF12345678DEADBEEF87654321" + with patch( + "homeassistant.components.telegram_bot.webhooks.secrets.choice", + side_effect=mock_secret_token, + ): + yield mock_secret_token + + +@pytest.fixture +def incorrect_secret_token(): + """Mock incorrect secret token.""" + return "AAAABBBBCCCCDDDDEEEEFFFF00009999" + + @pytest.fixture def update_message_command(): """Fixture for mocking an incoming update of type message/command.""" @@ -156,7 +173,9 @@ def update_callback_query(): @pytest.fixture -async def webhook_platform(hass, config_webhooks, mock_register_webhook): +async def webhook_platform( + hass, config_webhooks, mock_register_webhook, mock_generate_secret_token +): """Fixture for setting up the webhooks platform using appropriate config and mocks.""" await async_setup_component( hass, diff --git a/tests/components/telegram_bot/test_telegram_bot.py b/tests/components/telegram_bot/test_telegram_bot.py index b87f15b3ed3..be28f7be636 100644 --- a/tests/components/telegram_bot/test_telegram_bot.py +++ b/tests/components/telegram_bot/test_telegram_bot.py @@ -35,12 +35,17 @@ async def test_webhook_endpoint_generates_telegram_text_event( webhook_platform, hass_client: ClientSessionGenerator, update_message_text, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_text` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_text") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_message_text) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -56,12 +61,17 @@ async def test_webhook_endpoint_generates_telegram_command_event( webhook_platform, hass_client: ClientSessionGenerator, update_message_command, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_command` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_command") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_message_command) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_command, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -77,12 +87,17 @@ async def test_webhook_endpoint_generates_telegram_callback_event( webhook_platform, hass_client: ClientSessionGenerator, update_callback_query, + mock_generate_secret_token, ) -> None: """POST to the configured webhook endpoint and assert fired `telegram_callback` event.""" client = await hass_client() events = async_capture_events(hass, "telegram_callback") - response = await client.post(TELEGRAM_WEBHOOK_URL, json=update_callback_query) + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_callback_query, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, + ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -119,13 +134,16 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex webhook_platform, hass_client: ClientSessionGenerator, unauthorized_update_message_text, + mock_generate_secret_token, ) -> None: """Update with unauthorized user/chat should not trigger event.""" client = await hass_client() events = async_capture_events(hass, "telegram_text") response = await client.post( - TELEGRAM_WEBHOOK_URL, json=unauthorized_update_message_text + TELEGRAM_WEBHOOK_URL, + json=unauthorized_update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": mock_generate_secret_token}, ) assert response.status == 200 assert (await response.read()).decode("utf-8") == "" @@ -134,3 +152,39 @@ async def test_webhook_endpoint_unauthorized_update_doesnt_generate_telegram_tex await hass.async_block_till_done() assert len(events) == 0 + + +async def test_webhook_endpoint_without_secret_token_is_denied( + hass: HomeAssistant, + webhook_platform, + hass_client: ClientSessionGenerator, + update_message_text, +) -> None: + """Request without a secret token header should be denied.""" + client = await hass_client() + async_capture_events(hass, "telegram_text") + + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + ) + assert response.status == 401 + + +async def test_webhook_endpoint_invalid_secret_token_is_denied( + hass: HomeAssistant, + webhook_platform, + hass_client: ClientSessionGenerator, + update_message_text, + incorrect_secret_token, +) -> None: + """Request with an invalid secret token header should be denied.""" + client = await hass_client() + async_capture_events(hass, "telegram_text") + + response = await client.post( + TELEGRAM_WEBHOOK_URL, + json=update_message_text, + headers={"X-Telegram-Bot-Api-Secret-Token": incorrect_secret_token}, + ) + assert response.status == 401 From d0dc4d0963be2a017a37ab69ac34c74a664a1568 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 13:01:26 +0200 Subject: [PATCH 110/968] Add missing device class to sensor.DEVICE_CLASS_UNITS (#101256) --- homeassistant/components/sensor/const.py | 1 + tests/components/number/test_init.py | 10 ++++++++++ tests/components/sensor/test_init.py | 13 +++++++++++++ 3 files changed, 24 insertions(+) diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index 139725ee1ab..e8b1742f315 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -542,6 +542,7 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), + SensorDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), SensorDeviceClass.WATER: { UnitOfVolume.CENTUM_CUBIC_FEET, UnitOfVolume.CUBIC_FEET, diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 23758fe345d..3f612c421c8 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -901,3 +901,13 @@ async def test_name(hass: HomeAssistant) -> None: "mode": NumberMode.AUTO, "step": 1.0, } + + +def test_device_class_units(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - NumberDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(NUMBER_DEVICE_CLASS_UNITS) == set( + NumberDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {NumberDeviceClass.MONETARY} diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 07d44207c68..01dfb9b3649 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_STATE_CLASSES, DEVICE_CLASS_UNITS, DOMAIN as SENSOR_DOMAIN, + NON_NUMERIC_DEVICE_CLASSES, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -2483,3 +2484,15 @@ def test_async_rounded_state_registered_entity_with_display_precision( hass.states.async_set(entity_id, "-0.0") state = hass.states.get(entity_id) assert async_rounded_state(hass, entity_id, state) == "0.0000" + + +def test_device_class_units_state_classes(hass: HomeAssistant) -> None: + """Test all numeric device classes have unit and state class.""" + # DEVICE_CLASS_UNITS should include all device classes except: + # - SensorDeviceClass.MONETARY + # - Device classes enumerated in NON_NUMERIC_DEVICE_CLASSES + assert set(DEVICE_CLASS_UNITS) == set( + SensorDeviceClass + ) - NON_NUMERIC_DEVICE_CLASSES - {SensorDeviceClass.MONETARY} + # DEVICE_CLASS_STATE_CLASSES should include all device classes + assert set(DEVICE_CLASS_STATE_CLASSES) == set(SensorDeviceClass) From 85e782055bc4dc7f114bc6f7f545759d641762d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 13:07:56 +0200 Subject: [PATCH 111/968] Downgrade pylitterbot to 2023.4.5 (#101255) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index fd37365eb7d..9a3334cbaac 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.8"] + "requirements": ["pylitterbot==2023.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index f89f428beaa..073ed7f7687 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,7 +1830,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.8 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03c4981188d..265a25da0a8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.8 +pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 From 0fdf04391b1d115ea1e5dec07459b6b16b5e76b2 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 2 Oct 2023 11:40:06 +0000 Subject: [PATCH 112/968] Use class attrs and shorthand attrs for Shelly (#101249) --- homeassistant/components/shelly/climate.py | 18 +++++------------- homeassistant/components/shelly/cover.py | 12 ++++++------ homeassistant/components/shelly/entity.py | 4 ---- homeassistant/components/shelly/event.py | 2 -- 4 files changed, 11 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index a9712e62d25..35c18511860 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -148,6 +148,7 @@ class BlockSleepingClimate( self.last_state_attributes: Mapping[str, Any] self._preset_modes: list[str] = [] self._last_target_temp = SHTRV_01_TEMPERATURE_SETTINGS["default"] + self._attr_name = coordinator.name if self.block is not None and self.device_block is not None: self._unique_id = f"{self.coordinator.mac}-{self.block.description}" @@ -160,6 +161,9 @@ class BlockSleepingClimate( ] elif entry is not None: self._unique_id = entry.unique_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)}, + ) self._channel = cast(int, self._unique_id.split("_")[1]) @@ -173,11 +177,6 @@ class BlockSleepingClimate( """Set unique id of entity.""" return self._unique_id - @property - def name(self) -> str: - """Name of entity.""" - return self.coordinator.name - @property def target_temperature(self) -> float | None: """Set target temperature.""" @@ -256,13 +255,6 @@ class BlockSleepingClimate( """Preset available modes.""" return self._preset_modes - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self.coordinator.mac)}, - ) - def _check_is_off(self) -> bool: """Return if valve is off or on.""" return bool( @@ -354,7 +346,7 @@ class BlockSleepingClimate( severity=ir.IssueSeverity.ERROR, translation_key="device_not_calibrated", translation_placeholders={ - "device_name": self.name, + "device_name": self.coordinator.name, "ip_address": self.coordinator.device.ip_address, }, ) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index f2020597277..3d3e5be5b91 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -70,14 +70,14 @@ class BlockShellyCover(ShellyBlockEntity, CoverEntity): """Entity that controls a cover on block based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: """Initialize block cover.""" super().__init__(coordinator, block) self.control_result: dict[str, Any] | None = None - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) if self.coordinator.device.settings["rollers"][0]["positioning"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION @@ -146,14 +146,14 @@ class RpcShellyCover(ShellyRpcEntity, CoverEntity): """Entity that controls a cover on RPC based Shelly devices.""" _attr_device_class = CoverDeviceClass.SHUTTER + _attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) def __init__(self, coordinator: ShellyRpcCoordinator, id_: int) -> None: """Initialize rpc cover.""" super().__init__(coordinator, f"cover:{id_}") self._id = id_ - self._attr_supported_features = ( - CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP - ) if self.status["pos_control"]: self._attr_supported_features |= CoverEntityFeature.SET_POSITION diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 5afa5f8b727..92100eaddaf 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -326,7 +326,6 @@ class ShellyBlockEntity(CoordinatorEntity[ShellyBlockCoordinator]): super().__init__(coordinator) self.block = block self._attr_name = get_block_entity_name(coordinator.device, block) - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -364,7 +363,6 @@ class ShellyRpcEntity(CoordinatorEntity[ShellyRpcCoordinator]): """Initialize Shelly entity.""" super().__init__(coordinator) self.key = key - self._attr_should_poll = False self._attr_device_info = { "connections": {(CONNECTION_NETWORK_MAC, coordinator.mac)} } @@ -571,7 +569,6 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): self.block: Block | None = block # type: ignore[assignment] self.entity_description = description - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) @@ -643,7 +640,6 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): self.attribute = attribute self.entity_description = description - self._attr_should_poll = False self._attr_device_info = DeviceInfo( connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} ) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index 1b0fedd5cda..1b5cf911e85 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -122,7 +122,6 @@ async def async_setup_entry( class ShellyBlockEvent(ShellyBlockEntity, EventEntity): """Represent Block event entity.""" - _attr_should_poll = False entity_description: ShellyBlockEventDescription def __init__( @@ -160,7 +159,6 @@ class ShellyBlockEvent(ShellyBlockEntity, EventEntity): class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): """Represent RPC event entity.""" - _attr_should_poll = False entity_description: ShellyRpcEventDescription def __init__( From 4ee6f6c76607fba16d2d982b78891018d0de672c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 13:49:22 +0200 Subject: [PATCH 113/968] Bump aiowaqi to 2.0.0 (#101259) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 7b6bd3b8592..a866dc2c902 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==1.1.1"] + "requirements": ["aiowaqi==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 073ed7f7687..bbb1a8f4698 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -372,7 +372,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.1 +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 265a25da0a8..fcb4b4b1ef6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,7 +347,7 @@ aiovlc==0.1.0 aiovodafone==0.3.1 # homeassistant.components.waqi -aiowaqi==1.1.1 +aiowaqi==2.0.0 # homeassistant.components.watttime aiowatttime==0.1.1 From 100b6fd06fbffff3ce4ca375defed61233915fdd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 2 Oct 2023 13:52:56 +0200 Subject: [PATCH 114/968] Fix flaky lru_cache test (#101252) --- tests/helpers/test_entity_values.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/test_entity_values.py b/tests/helpers/test_entity_values.py index 1ac8e480f51..f32db73a788 100644 --- a/tests/helpers/test_entity_values.py +++ b/tests/helpers/test_entity_values.py @@ -9,6 +9,7 @@ ent = "test.test" def test_override_single_value() -> None: """Test values with exact match.""" store = EV({ent: {"key": "value"}}) + store.get.cache_clear() assert store.get(ent) == {"key": "value"} assert store.get.cache_info().currsize == 1 assert store.get.cache_info().misses == 1 From e23e71279f09824260127debae2184485688b662 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 2 Oct 2023 12:56:39 +0100 Subject: [PATCH 115/968] Add extra validation in private_ble_device config flow (#101254) --- .../private_ble_device/config_flow.py | 33 ++++++++++++++----- .../private_ble_device/test_config_flow.py | 26 +++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py index 5bf130a0396..4fec68e507e 100644 --- a/homeassistant/components/private_ble_device/config_flow.py +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -19,6 +19,30 @@ _LOGGER = logging.getLogger(__name__) CONF_IRK = "irk" +def _parse_irk(irk: str) -> bytes | None: + if irk.startswith("irk:"): + irk = irk[4:] + + if irk.endswith("="): + try: + irk_bytes = bytes(reversed(base64.b64decode(irk))) + except binascii.Error: + # IRK is not valid base64 + return None + else: + try: + irk_bytes = binascii.unhexlify(irk) + except binascii.Error: + # IRK is not correctly hex encoded + return None + + if len(irk_bytes) != 16: + # IRK must be 16 bytes when decoded + return None + + return irk_bytes + + class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for BLE Device Tracker.""" @@ -35,15 +59,8 @@ class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: irk = user_input[CONF_IRK] - if irk.startswith("irk:"): - irk = irk[4:] - if irk.endswith("="): - irk_bytes = bytes(reversed(base64.b64decode(irk))) - else: - irk_bytes = binascii.unhexlify(irk) - - if len(irk_bytes) != 16: + if not (irk_bytes := _parse_irk(irk)): errors[CONF_IRK] = "irk_not_valid" elif not (service_info := async_last_service_info(self.hass, irk_bytes)): errors[CONF_IRK] = "irk_not_found" diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py index aa8ea0d905c..bb58cfedb29 100644 --- a/tests/components/private_ble_device/test_config_flow.py +++ b/tests/components/private_ble_device/test_config_flow.py @@ -42,6 +42,32 @@ async def test_invalid_irk(hass: HomeAssistant, enable_bluetooth: None) -> None: assert_form_error(result, "irk", "irk_not_valid") +async def test_invalid_irk_base64(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "Ucredacted4T8n!!ZZZ=="} + ) + assert_form_error(result, "irk", "irk_not_valid") + + +async def test_invalid_irk_hex(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test invalid irk.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"irk": "irk:abcdefghi"} + ) + assert_form_error(result, "irk", "irk_not_valid") + + async def test_irk_not_found(hass: HomeAssistant, enable_bluetooth: None) -> None: """Test irk not found.""" result = await hass.config_entries.flow.async_init( From e18e12a2df257399ef7aa22f4fdbec86673e2bf3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 14:12:06 +0200 Subject: [PATCH 116/968] Fix color temperature setting in broadlink light (#101251) --- homeassistant/components/broadlink/light.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/broadlink/light.py b/homeassistant/components/broadlink/light.py index 57797ca592a..fde6d322bc6 100644 --- a/homeassistant/components/broadlink/light.py +++ b/homeassistant/components/broadlink/light.py @@ -6,7 +6,7 @@ from broadlink.exceptions import BroadlinkException from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP, + ATTR_COLOR_TEMP_KELVIN, ATTR_HS_COLOR, ColorMode, LightEntity, @@ -45,6 +45,8 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_min_color_temp_kelvin = 2700 + _attr_max_color_temp_kelvin = 6500 def __init__(self, device): """Initialize the light.""" @@ -79,7 +81,7 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): self._attr_hs_color = [data["hue"], data["saturation"]] if "colortemp" in data: - self._attr_color_temp = round((data["colortemp"] - 2700) / 100 + 153) + self._attr_color_temp_kelvin = data["colortemp"] if "bulb_colormode" in data: if data["bulb_colormode"] == BROADLINK_COLOR_MODE_RGB: @@ -107,9 +109,9 @@ class BroadlinkLight(BroadlinkEntity, LightEntity): state["saturation"] = int(hs_color[1]) state["bulb_colormode"] = BROADLINK_COLOR_MODE_RGB - elif ATTR_COLOR_TEMP in kwargs: - color_temp = kwargs[ATTR_COLOR_TEMP] - state["colortemp"] = (color_temp - 153) * 100 + 2700 + elif ATTR_COLOR_TEMP_KELVIN in kwargs: + color_temp = kwargs[ATTR_COLOR_TEMP_KELVIN] + state["colortemp"] = color_temp state["bulb_colormode"] = BROADLINK_COLOR_MODE_WHITE await self._async_set_state(state) From a618e8d1cf24b712b9ed999bcd0ef032f2007f21 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Mon, 2 Oct 2023 14:15:54 +0200 Subject: [PATCH 117/968] Fix loop in progress config flow (#97229) * Fix data entry flow with multiple steps * Update a test * Update description and add a show progress change test --------- Co-authored-by: Martin Hjelmare --- homeassistant/data_entry_flow.py | 15 ++-- tests/test_data_entry_flow.py | 116 ++++++++++++++++++++++++++++--- 2 files changed, 119 insertions(+), 12 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 63cbfda5b9b..e22d4229511 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -320,10 +320,17 @@ class FlowManager(abc.ABC): ) # If the result has changed from last result, fire event to update - # the frontend. - if ( - cur_step["step_id"] != result.get("step_id") - or result["type"] == FlowResultType.SHOW_PROGRESS + # the frontend. The result is considered to have changed if: + # - The step has changed + # - The step is same but result type is SHOW_PROGRESS and progress_action + # or description_placeholders has changed + if cur_step["step_id"] != result.get("step_id") or ( + result["type"] == FlowResultType.SHOW_PROGRESS + and ( + cur_step["progress_action"] != result.get("progress_action") + or cur_step["description_placeholders"] + != result.get("description_placeholders") + ) ): # Tell frontend to reload the flow state. self.hass.bus.async_fire( diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 168f97ba779..e6a28fc2e4f 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -344,14 +344,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: VERSION = 5 data = None task_one_done = False + task_two_done = False async def async_step_init(self, user_input=None): - if not user_input: - if not self.task_one_done: + if user_input and "task_finished" in user_input: + if user_input["task_finished"] == 1: self.task_one_done = True - progress_action = "task_one" - else: - progress_action = "task_two" + elif user_input["task_finished"] == 2: + self.task_two_done = True + + if not self.task_one_done: + progress_action = "task_one" + elif not self.task_two_done: + progress_action = "task_two" + if not self.task_one_done or not self.task_two_done: return self.async_show_progress( step_id="init", progress_action=progress_action, @@ -376,7 +382,7 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: # Mimic task one done and moving to task two # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"]) + result = await manager.async_configure(result["flow_id"], {"task_finished": 1}) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS assert result["progress_action"] == "task_two" @@ -388,13 +394,20 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: "refresh": True, } + # Frontend refreshes the flow + result = await manager.async_configure(result["flow_id"]) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_two" + # Mimic task two done and continuing step # Called by integrations: `hass.config_entries.flow.async_configure(…)` - result = await manager.async_configure(result["flow_id"], {"title": "Hello"}) + result = await manager.async_configure( + result["flow_id"], {"task_finished": 2, "title": "Hello"} + ) assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS_DONE await hass.async_block_till_done() - assert len(events) == 2 + assert len(events) == 2 # 1 for task one and 1 for task two assert events[1].data == { "handler": "test", "flow_id": result["flow_id"], @@ -407,6 +420,93 @@ async def test_show_progress(hass: HomeAssistant, manager) -> None: assert result["title"] == "Hello" +async def test_show_progress_fires_only_when_changed( + hass: HomeAssistant, manager +) -> None: + """Test show progress change logic.""" + manager.hass = hass + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + data = None + + async def async_step_init(self, user_input=None): + if user_input: + progress_action = user_input["progress_action"] + description_placeholders = user_input["description_placeholders"] + return self.async_show_progress( + step_id="init", + progress_action=progress_action, + description_placeholders=description_placeholders, + ) + return self.async_show_progress(step_id="init", progress_action="task_one") + + async def async_step_finish(self, user_input=None): + return self.async_create_entry(title=self.data["title"], data=self.data) + + events = async_capture_events( + hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED + ) + + async def test_change( + flow_id, + events, + progress_action, + description_placeholders_progress, + number_of_events, + is_change, + ) -> None: + # Called by integrations: `hass.config_entries.flow.async_configure(…)` + result = await manager.async_configure( + flow_id, + { + "progress_action": progress_action, + "description_placeholders": { + "progress": description_placeholders_progress + }, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == progress_action + assert ( + result["description_placeholders"]["progress"] + == description_placeholders_progress + ) + + await hass.async_block_till_done() + assert len(events) == number_of_events + if is_change: + assert events[number_of_events - 1].data == { + "handler": "test", + "flow_id": result["flow_id"], + "refresh": True, + } + + result = await manager.async_init("test") + assert result["type"] == data_entry_flow.FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "task_one" + assert len(manager.async_progress()) == 1 + assert len(manager.async_progress_by_handler("test")) == 1 + assert manager.async_get(result["flow_id"])["handler"] == "test" + + # Mimic task one tests + await test_change( + result["flow_id"], events, "task_one", 0, 1, True + ) # change (progress action) + await test_change(result["flow_id"], events, "task_one", 0, 1, False) # no change + await test_change( + result["flow_id"], events, "task_one", 25, 2, True + ) # change (description placeholder) + await test_change( + result["flow_id"], events, "task_two", 50, 3, True + ) # change (progress action and description placeholder) + await test_change(result["flow_id"], events, "task_two", 50, 3, False) # no change + await test_change( + result["flow_id"], events, "task_two", 100, 4, True + ) # change (description placeholder) + + async def test_abort_flow_exception(manager) -> None: """Test that the AbortFlow exception works.""" From 02f82d04feaa386bb30535ca860be07f27beee77 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 14:30:33 +0200 Subject: [PATCH 118/968] Add documentation URL for the Home Assistant Green (#101263) --- homeassistant/components/homeassistant_green/hardware.py | 3 ++- tests/components/homeassistant_green/test_hardware.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_green/hardware.py b/homeassistant/components/homeassistant_green/hardware.py index 2b5268f8d03..c7b1641c09c 100644 --- a/homeassistant/components/homeassistant_green/hardware.py +++ b/homeassistant/components/homeassistant_green/hardware.py @@ -9,6 +9,7 @@ from homeassistant.exceptions import HomeAssistantError from .const import DOMAIN BOARD_NAME = "Home Assistant Green" +DOCUMENTATION_URL = "https://green.home-assistant.io/documentation/" MANUFACTURER = "homeassistant" MODEL = "green" @@ -39,6 +40,6 @@ def async_info(hass: HomeAssistant) -> list[HardwareInfo]: config_entries=config_entries, dongle=None, name=BOARD_NAME, - url=None, + url=DOCUMENTATION_URL, ) ] diff --git a/tests/components/homeassistant_green/test_hardware.py b/tests/components/homeassistant_green/test_hardware.py index 8aacf09978d..0221bf3a577 100644 --- a/tests/components/homeassistant_green/test_hardware.py +++ b/tests/components/homeassistant_green/test_hardware.py @@ -54,7 +54,7 @@ async def test_hardware_info( "config_entries": [config_entry.entry_id], "dongle": None, "name": "Home Assistant Green", - "url": None, + "url": "https://green.home-assistant.io/documentation/", } ] } From 15f945c47e38f66139de3ef5cc44821e9607a3cc Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 2 Oct 2023 14:31:25 +0200 Subject: [PATCH 119/968] Remove invalid doc about multi origin/dest in google_travel_time (#101215) --- homeassistant/components/google_travel_time/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 78b84038c7f..270f8fe31e2 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -3,7 +3,7 @@ "config": { "step": { "user": { - "description": "When specifying the origin and destination, you can supply one or more locations separated by the pipe character, in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", + "description": "You can specify the origin and destination in the form of an address, latitude/longitude coordinates, or a Google place ID. When specifying the location using a Google place ID, the ID must be prefixed with `place_id:`.", "data": { "name": "[%key:common::config_flow::data::name%]", "api_key": "[%key:common::config_flow::data::api_key%]", From 9eaf326c0eb119f2678fe95379e8d84bb8459db4 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Mon, 2 Oct 2023 14:20:19 +0100 Subject: [PATCH 120/968] Split get users into chunks of 100 for Twitch sensors (#101211) * Split get users into chunks of 100 * Move to own function --- homeassistant/components/twitch/sensor.py | 26 +++++++++++++++++------ 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 11d6611ef99..05fd3fa3e71 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -52,6 +52,11 @@ STATE_OFFLINE = "offline" STATE_STREAMING = "streaming" +def chunk_list(lst: list, chunk_size: int) -> list[list]: + """Split a list into chunks of chunk_size.""" + return [lst[i : i + chunk_size] for i in range(0, len(lst), chunk_size)] + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -94,13 +99,20 @@ async def async_setup_entry( """Initialize entries.""" client = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - TwitchSensor(channel, client) - async for channel in client.get_users(logins=entry.options[CONF_CHANNELS]) - ], - True, - ) + channels = entry.options[CONF_CHANNELS] + + entities: list[TwitchSensor] = [] + + # Split channels into chunks of 100 to avoid hitting the rate limit + for chunk in chunk_list(channels, 100): + entities.extend( + [ + TwitchSensor(channel, client) + async for channel in client.get_users(logins=chunk) + ] + ) + + async_add_entities(entities, True) class TwitchSensor(SensorEntity): From f72f95549c316dde0b33691ecf0d1b596c1a5b68 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 15:22:54 +0200 Subject: [PATCH 121/968] Use DOMAIN constant in command_line (#101269) --- homeassistant/components/command_line/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index 5d057d40e1b..e1a051cea33 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -171,8 +171,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _reload_config(call: Event | ServiceCall) -> None: """Reload Command Line.""" - reload_config = await async_integration_yaml_config(hass, "command_line") - reset_platforms = async_get_platforms(hass, "command_line") + reload_config = await async_integration_yaml_config(hass, DOMAIN) + reset_platforms = async_get_platforms(hass, DOMAIN) for reset_platform in reset_platforms: _LOGGER.debug("Reload resetting platform: %s", reset_platform.domain) await reset_platform.async_reset() From b5f71f9ec790571bbccd881abc97abab083d2ee2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 15:24:12 +0200 Subject: [PATCH 122/968] Fix stale docstring in intent_script (#101270) --- homeassistant/components/intent_script/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index f0cf36b5607..b3e7c44f086 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -64,7 +64,7 @@ CONFIG_SCHEMA = vol.Schema( async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None: - """Handle start Intent Script service call.""" + """Handle reload Intent Script service call.""" new_config = await async_integration_yaml_config(hass, DOMAIN) existing_intents = hass.data[DOMAIN] From cab30085c5987cb4cd21bc4e8135a52de86df064 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 2 Oct 2023 15:57:04 +0200 Subject: [PATCH 123/968] Fix typo in config.py (#101268) --- homeassistant/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 7c3bd2e7bfe..6a0425f971e 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -385,7 +385,7 @@ def _write_default_config(config_dir: str) -> bool: async def async_hass_config_yaml(hass: HomeAssistant) -> dict: """Load YAML from a Home Assistant configuration file. - This function allow a component inside the asyncio loop to reload its + This function allows a component inside the asyncio loop to reload its configuration by itself. Include package merge. """ secrets = Secrets(Path(hass.config.config_dir)) From 74b3c5c6900d301e6d5837c329d9ecb9d7d287a6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 2 Oct 2023 07:56:46 -0700 Subject: [PATCH 124/968] Modernize fitbit sensors (#101179) * Improve fitbit sensors with state class and entiy category * Revert sensor format changes --- homeassistant/components/fitbit/sensor.py | 59 +++++++++++++++++++ .../fitbit/snapshots/test_sensor.ambr | 17 ++++++ 2 files changed, 76 insertions(+) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 8fbd9a25474..313106a0d0f 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -29,6 +29,7 @@ from homeassistant.const import ( CONF_TOKEN, CONF_UNIT_SYSTEM, PERCENTAGE, + EntityCategory, UnitOfLength, UnitOfMass, UnitOfTime, @@ -137,6 +138,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="cal", icon="mdi:fire", scope="activity", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/calories", @@ -144,6 +147,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="cal", icon="mdi:fire", scope="activity", + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/caloriesBMR", @@ -152,6 +156,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:fire", scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/distance", @@ -161,6 +167,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( value_fn=_distance_value_fn, unit_fn=_distance_unit, scope="activity", + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/elevation", @@ -169,6 +176,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, scope="activity", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/floors", @@ -176,6 +185,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="floors", icon="mdi:walk", scope="activity", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/heart", @@ -184,6 +195,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), scope="heartrate", + state_class=SensorStateClass.MEASUREMENT, ), FitbitSensorEntityDescription( key="activities/minutesFairlyActive", @@ -192,6 +204,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DURATION, scope="activity", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesLightlyActive", @@ -200,6 +214,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DURATION, scope="activity", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesSedentary", @@ -208,6 +224,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, scope="activity", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/minutesVeryActive", @@ -216,6 +234,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:run", device_class=SensorDeviceClass.DURATION, scope="activity", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/steps", @@ -223,6 +243,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="steps", icon="mdi:walk", scope="activity", + state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( key="activities/tracker/activityCalories", @@ -231,6 +252,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:fire", scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/calories", @@ -239,6 +262,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:fire", scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/distance", @@ -249,6 +274,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( unit_fn=_distance_unit, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/elevation", @@ -258,6 +285,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( unit_fn=_elevation_unit, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/floors", @@ -266,6 +295,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesFairlyActive", @@ -275,6 +306,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DURATION, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesLightlyActive", @@ -284,6 +317,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DURATION, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesSedentary", @@ -293,6 +328,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DURATION, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/minutesVeryActive", @@ -302,6 +339,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DURATION, scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="activities/tracker/steps", @@ -310,6 +349,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", scope="activity", entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/bmi", @@ -320,6 +361,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( value_fn=_body_value_fn, scope="weight", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/fat", @@ -330,6 +372,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( value_fn=_body_value_fn, scope="weight", entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="body/weight", @@ -347,6 +390,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="times awaken", icon="mdi:sleep", scope="sleep", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/efficiency", @@ -355,6 +400,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, scope="sleep", + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAfterWakeup", @@ -363,6 +409,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, scope="sleep", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAsleep", @@ -371,6 +419,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, scope="sleep", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesAwake", @@ -379,6 +429,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, scope="sleep", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/minutesToFallAsleep", @@ -387,6 +439,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, scope="sleep", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( key="sleep/timeInBed", @@ -395,6 +449,8 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, scope="sleep", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -404,6 +460,7 @@ SLEEP_START_TIME = FitbitSensorEntityDescription( name="Sleep Start Time", icon="mdi:clock", scope="sleep", + entity_category=EntityCategory.DIAGNOSTIC, ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( key="sleep/startTime", @@ -411,6 +468,7 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( icon="mdi:clock", value_fn=_clock_format_12h, scope="sleep", + entity_category=EntityCategory.DIAGNOSTIC, ) FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( @@ -418,6 +476,7 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( name="Battery", icon="mdi:battery", scope="settings", + entity_category=EntityCategory.DIAGNOSTIC, ) FITBIT_RESOURCES_KEYS: Final[list[str]] = [ diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr index 719a2f8a6b8..4af82c6815a 100644 --- a/tests/components/fitbit/snapshots/test_sensor.ambr +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -6,6 +6,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Activity Calories', 'icon': 'mdi:fire', + 'state_class': , 'unit_of_measurement': 'cal', }), 'fitbit-api-user-id-1_activities/activityCalories', @@ -18,6 +19,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Calories', 'icon': 'mdi:fire', + 'state_class': , 'unit_of_measurement': 'cal', }), 'fitbit-api-user-id-1_activities/calories', @@ -30,6 +32,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Steps', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': 'steps', }), 'fitbit-api-user-id-1_activities/steps', @@ -82,6 +85,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Awakenings Count', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': 'times awaken', }), 'fitbit-api-user-id-1_sleep/awakeningsCount', @@ -108,6 +112,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes After Wakeup', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAfterWakeup', @@ -121,6 +126,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes Asleep', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAsleep', @@ -134,6 +140,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes Awake', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesAwake', @@ -147,6 +154,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Minutes to Fall Asleep', 'icon': 'mdi:sleep', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/minutesToFallAsleep', @@ -160,6 +168,7 @@ 'device_class': 'distance', 'friendly_name': 'Distance', 'icon': 'mdi:map-marker', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/distance', @@ -184,6 +193,7 @@ 'device_class': 'duration', 'friendly_name': 'Sleep Time in Bed', 'icon': 'mdi:hotel', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_sleep/timeInBed', @@ -197,6 +207,7 @@ 'device_class': 'distance', 'friendly_name': 'Elevation', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/elevation', @@ -209,6 +220,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Floors', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': 'floors', }), 'fitbit-api-user-id-1_activities/floors', @@ -221,6 +233,7 @@ 'attribution': 'Data provided by Fitbit.com', 'friendly_name': 'Resting Heart Rate', 'icon': 'mdi:heart-pulse', + 'state_class': , 'unit_of_measurement': 'bpm', }), 'fitbit-api-user-id-1_activities/heart', @@ -234,6 +247,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Fairly Active', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesFairlyActive', @@ -247,6 +261,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Lightly Active', 'icon': 'mdi:walk', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesLightlyActive', @@ -260,6 +275,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Sedentary', 'icon': 'mdi:seat-recline-normal', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesSedentary', @@ -273,6 +289,7 @@ 'device_class': 'duration', 'friendly_name': 'Minutes Very Active', 'icon': 'mdi:run', + 'state_class': , 'unit_of_measurement': , }), 'fitbit-api-user-id-1_activities/minutesVeryActive', From 054407722db12549c642279c3483b89cf701c53c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 2 Oct 2023 11:03:53 -0400 Subject: [PATCH 125/968] Bump python-myq to 3.1.11 (#101266) --- homeassistant/components/myq/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 02bf454bc3e..5efcb8e1bb0 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["python-myq==3.1.9"] + "requirements": ["python-myq==3.1.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index bbb1a8f4698..322bcec0853 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2149,7 +2149,7 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.myq -python-myq==3.1.9 +python-myq==3.1.11 # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fcb4b4b1ef6..20103af7e52 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1599,7 +1599,7 @@ python-matter-server==3.7.0 python-miio==0.5.12 # homeassistant.components.myq -python-myq==3.1.9 +python-myq==3.1.11 # homeassistant.components.mystrom python-mystrom==2.2.0 From 35616e100d349566f2652db4580e5bd16d27f490 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 2 Oct 2023 17:15:41 +0200 Subject: [PATCH 126/968] Discover switch entities from Hue behavior_script instances (#101262) --- homeassistant/components/hue/strings.json | 8 ++ homeassistant/components/hue/switch.py | 74 +++++++++++++------ .../components/hue/fixtures/v2_resources.json | 44 +++++++---- tests/components/hue/test_switch.py | 11 ++- 4 files changed, 100 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 1224abb240e..a095c290b12 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -104,6 +104,14 @@ "unidirectional_incoming": "Unidirectional incoming" } } + }, + "switch": { + "automation": { + "state": { + "on": "[%key:common::state::enabled%", + "off": "[%key:common::state::disabled%" + } + } } }, "options": { diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index 31b5de3a9a1..0fb2ebd6b52 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -4,6 +4,7 @@ from __future__ import annotations from typing import Any, TypeAlias from aiohue.v2 import HueBridgeV2 +from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController from aiohue.v2.controllers.events import EventType from aiohue.v2.controllers.sensors import ( LightLevel, @@ -12,7 +13,11 @@ from aiohue.v2.controllers.sensors import ( MotionController, ) -from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback @@ -22,7 +27,9 @@ from .bridge import HueBridge from .const import DOMAIN from .v2.entity import HueBaseEntity -ControllerType: TypeAlias = LightLevelController | MotionController +ControllerType: TypeAlias = ( + BehaviorInstanceController | LightLevelController | MotionController +) SensingService: TypeAlias = LightLevel | Motion @@ -43,11 +50,18 @@ async def async_setup_entry( @callback def register_items(controller: ControllerType): @callback - def async_add_entity(event_type: EventType, resource: SensingService) -> None: + def async_add_entity( + event_type: EventType, resource: SensingService | BehaviorInstance + ) -> None: """Add entity from Hue resource.""" - async_add_entities( - [HueSensingServiceEnabledEntity(bridge, controller, resource)] - ) + if isinstance(resource, BehaviorInstance): + async_add_entities( + [HueBehaviorInstanceEnabledEntity(bridge, controller, resource)] + ) + else: + async_add_entities( + [HueSensingServiceEnabledEntity(bridge, controller, resource)] + ) # add all current items in controller for item in controller: @@ -63,24 +77,13 @@ async def async_setup_entry( # setup for each switch-type hue resource register_items(api.sensors.motion) register_items(api.sensors.light_level) + register_items(api.config.behavior_instance) -class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): - """Representation of a Switch entity from Hue SensingService.""" +class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): + """Representation of a Switch entity from a Hue resource that can be toggled enabled.""" - _attr_entity_category = EntityCategory.CONFIG - _attr_device_class = SwitchDeviceClass.SWITCH - - def __init__( - self, - bridge: HueBridge, - controller: LightLevelController | MotionController, - resource: SensingService, - ) -> None: - """Initialize the entity.""" - super().__init__(bridge, controller, resource) - self.resource = resource - self.controller = controller + controller: BehaviorInstanceController | LightLevelController | MotionController @property def is_on(self) -> bool: @@ -98,3 +101,32 @@ class HueSensingServiceEnabledEntity(HueBaseEntity, SwitchEntity): await self.bridge.async_request_call( self.controller.set_enabled, self.resource.id, enabled=False ) + + +class HueSensingServiceEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity from Hue SensingService.""" + + entity_description = SwitchEntityDescription( + key="behavior_instance", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + ) + + +class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue Behavior Instance.""" + + resource: BehaviorInstance + + entity_description = SwitchEntityDescription( + key="behavior_instance", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=False, + translation_key="automation", + ) + + @property + def name(self) -> str: + """Return name for this entity.""" + return f"Automation: {self.resource.metadata.name}" diff --git a/tests/components/hue/fixtures/v2_resources.json b/tests/components/hue/fixtures/v2_resources.json index 24f433f539c..662e1107ca9 100644 --- a/tests/components/hue/fixtures/v2_resources.json +++ b/tests/components/hue/fixtures/v2_resources.json @@ -2050,37 +2050,53 @@ "type": "temperature" }, { + "id": "9ad57767-e622-4f91-9086-2e5573bc781b", + "type": "behavior_instance", + "script_id": "e73bc72d-96b1-46f8-aa57-729861f80c78", + "enabled": true, + "state": { + "timer_state": "stopped" + }, "configuration": { - "end_state": "last_state", + "duration": { + "seconds": 60 + }, + "what": [ + { + "group": { + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" + }, + "recall": { + "rid": "732ff1d9-76a7-4630-aad0-c8acc499bb0b", + "rtype": "recipe" + } + } + ], "where": [ { "group": { - "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", - "rtype": "entertainment_configuration" + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" } } ] }, "dependees": [ { - "level": "critical", "target": { - "rid": "c14cf1cf-6c7a-4984-b8bb-c5b71aeb70fc", - "rtype": "entertainment_configuration" + "rid": "5e799732-e82e-46ab-b5d9-52b701bd7cbc", + "rtype": "room" }, + "level": "critical", "type": "ResourceDependee" } ], - "enabled": true, - "id": "0670cfb1-2bd7-4237-a0e3-1827a44d7231", + "status": "running", "last_error": "", "metadata": { - "name": "state_after_streaming" - }, - "migrated_from": "/resourcelinks/47450", - "script_id": "7719b841-6b3d-448d-a0e7-601ae9edb6a2", - "status": "running", - "type": "behavior_instance" + "name": "Timer Test" + } }, { "configuration_schema": { diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index a576b88a7c3..e8cad2bc802 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -14,8 +14,8 @@ async def test_switch( await setup_platform(hass, mock_bridge_v2, "switch") # there shouldn't have been any requests at this point assert len(mock_bridge_v2.mock_requests) == 0 - # 3 entities should be created from test data - assert len(hass.states.async_all()) == 3 + # 4 entities should be created from test data + assert len(hass.states.async_all()) == 4 # test config switch to enable/disable motion sensor test_entity = hass.states.get("switch.hue_motion_sensor_motion") @@ -24,6 +24,13 @@ async def test_switch( assert test_entity.state == "on" assert test_entity.attributes["device_class"] == "switch" + # test config switch to enable/disable a behavior_instance resource (=builtin automation) + test_entity = hass.states.get("switch.automation_timer_test") + assert test_entity is not None + assert test_entity.name == "Automation: Timer Test" + assert test_entity.state == "on" + assert test_entity.attributes["device_class"] == "switch" + async def test_switch_turn_on_service( hass: HomeAssistant, mock_bridge_v2, v2_resources_test_data From 1a9c98e837bf7fc5e629103f76b2d56ca07e75d7 Mon Sep 17 00:00:00 2001 From: Mike <7278201+mike391@users.noreply.github.com> Date: Mon, 2 Oct 2023 11:53:54 -0400 Subject: [PATCH 127/968] Add support for Levoit Vital 100S Purifier (#101273) Add support for Levoit Vital 100S Purifier. --- homeassistant/components/vesync/const.py | 13 ++++++++----- homeassistant/components/vesync/fan.py | 3 +++ 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index f87f1cf3a8a..a0e5b9da52e 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -36,9 +36,12 @@ SKU_TO_BASE_DEVICE = { "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S, - "LAP-V201S-AASR": "Vital200S", - "LAP-V201S-WJP": "Vital200S", - "LAP-V201S-WEU": "Vital200S", - "LAP-V201S-WUS": "Vital200S", - "LAP-V201-AUSR": "Vital200S", + "Vital200S": "Vital200S", + "LAP-V201S-AASR": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WJP": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WEU": "Vital200S", # Alt ID Model Vital200S + "LAP-V201S-WUS": "Vital200S", # Alt ID Model Vital200S + "LAP-V201-AUSR": "Vital200S", # Alt ID Model Vital200S + "Vital100S": "Vital100S", + "LAP-V102S-WUS": "Vital100S", # Alt ID Model Vital100S, } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 87934ced81f..326e7daf12c 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -28,6 +28,7 @@ DEV_TYPE_TO_HA = { "Core400S": "fan", "Core600S": "fan", "Vital200S": "fan", + "Vital100S": "fan", } FAN_MODE_AUTO = "auto" @@ -41,6 +42,7 @@ PRESET_MODES = { "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], + "Vital100S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -49,6 +51,7 @@ SPEED_RANGE = { # off is not included "Core400S": (1, 4), "Core600S": (1, 4), "Vital200S": (1, 4), + "Vital100S": (1, 4), } From 6fa3078cfca6833cbb5e60f5471a80afc1ce547a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 2 Oct 2023 19:10:15 +0200 Subject: [PATCH 128/968] Revert "Use shorthand attributes in Telldus live" (#101281) --- homeassistant/components/tellduslive/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 06b505d9574..e15f89888b1 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -142,7 +142,6 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): def __init__(self, client, device_id): """Initialize TelldusLiveSensor.""" super().__init__(client, device_id) - self._attr_unique_id = "{}-{}-{}".format(*device_id) if desc := SENSOR_TYPES.get(self._type): self.entity_description = desc else: @@ -190,3 +189,8 @@ class TelldusLiveSensor(TelldusLiveEntity, SensorEntity): if self._type == SENSOR_TYPE_LUMINANCE: return self._value_as_luminance return self._value + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "{}-{}-{}".format(*self._id) From e4cb19f20dcdcca4d8ba6c0a5ad5b564fb205ec8 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Mon, 2 Oct 2023 14:11:16 -0400 Subject: [PATCH 129/968] Bump python-roborock to 0.34.6 (#101147) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/components/roborock/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/test_switch.py | 2 +- tests/components/roborock/test_time.py | 2 +- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index dfd5a9ee1c7..6882754f49a 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.34.1"] + "requirements": ["python-roborock==0.34.6"] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index c46eb814151..53c536494f9 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -176,7 +176,8 @@ "moderate": "Moderate", "high": "High", "intense": "Intense", - "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]" + "custom": "[%key:component::roborock::entity::select::mop_mode::state::custom%]", + "custom_water_flow": "Custom water flow" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index 322bcec0853..36e566cb26e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2174,7 +2174,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20103af7e52..8cff8948762 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1618,7 +1618,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.1 +python-roborock==0.34.6 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py index 40ecdc267ed..fb301390fee 100644 --- a/tests/components/roborock/test_switch.py +++ b/tests/components/roborock/test_switch.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "switch", diff --git a/tests/components/roborock/test_time.py b/tests/components/roborock/test_time.py index 6ba996ca23f..1cf2fe6bed5 100644 --- a/tests/components/roborock/test_time.py +++ b/tests/components/roborock/test_time.py @@ -27,7 +27,7 @@ async def test_update_success( # Ensure that the entity exist, as these test can pass even if there is no entity. assert hass.states.get(entity_id) is not None with patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + "homeassistant.components.roborock.coordinator.RoborockLocalClient._send_command" ) as mock_send_message: await hass.services.async_call( "time", From f248b693d702356016375329994ad3661af64853 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 2 Oct 2023 20:12:43 +0200 Subject: [PATCH 130/968] Update pylint to 3.0.0 (#101282) --- pylint/plugins/hass_enforce_super_call.py | 2 +- pylint/plugins/hass_enforce_type_hints.py | 2 +- pylint/plugins/hass_imports.py | 4 ++-- pylint/plugins/hass_inheritance.py | 2 +- pylint/plugins/hass_logger.py | 2 +- requirements_test.txt | 4 ++-- 6 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py index db4b2d4a5d7..f2efb8bc8a2 100644 --- a/pylint/plugins/hass_enforce_super_call.py +++ b/pylint/plugins/hass_enforce_super_call.py @@ -11,7 +11,7 @@ METHODS = { } -class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] +class HassEnforceSuperCallChecker(BaseChecker): """Checker for super calls.""" name = "hass_enforce_super_call" diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 85b1b1370a1..d3546dc7939 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -3014,7 +3014,7 @@ def _is_test_function(module_name: str, node: nodes.FunctionDef) -> bool: return module_name.startswith("tests.") and node.name.startswith("test_") -class HassTypeHintChecker(BaseChecker): # type: ignore[misc] +class HassTypeHintChecker(BaseChecker): """Checker for setup type hints.""" name = "hass_enforce_type_hints" diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index 8b3aea61ff4..f28986b90e2 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -385,7 +385,7 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { } -class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] +class HassImportsFormatChecker(BaseChecker): """Checker for imports.""" name = "hass_imports" @@ -415,7 +415,7 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def __init__(self, linter: PyLinter | None = None) -> None: + def __init__(self, linter: PyLinter) -> None: """Initialize the HassImportsFormatChecker.""" super().__init__(linter) self.current_package: str | None = None diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py index 716479202c7..7ae24ec6e6d 100644 --- a/pylint/plugins/hass_inheritance.py +++ b/pylint/plugins/hass_inheritance.py @@ -21,7 +21,7 @@ def _get_module_platform(module_name: str) -> str | None: return platform.lstrip(".") if platform else "__init__" -class HassInheritanceChecker(BaseChecker): # type: ignore[misc] +class HassInheritanceChecker(BaseChecker): """Checker for invalid inheritance.""" name = "hass_inheritance" diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index bfa05001304..e92fad2bdc0 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -9,7 +9,7 @@ LOGGER_NAMES = ("LOGGER", "_LOGGER") LOG_LEVEL_ALLOWED_LOWER_START = ("debug",) -class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] +class HassLoggerFormatChecker(BaseChecker): """Checker for logger invocations.""" name = "hass_logger" diff --git a/requirements_test.txt b/requirements_test.txt index d12ee6de114..42f96b1f507 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.8 +astroid==3.0.0 coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 pre-commit==3.4.0 pydantic==1.10.12 -pylint==2.17.6 +pylint==3.0.0 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 From 9d242bf45f56699ef09f5c40e755dc9059786fe0 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 2 Oct 2023 12:44:47 -0600 Subject: [PATCH 131/968] Bump pylitterbot to 2023.4.9 (#101285) --- homeassistant/components/litterrobot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/litterrobot/manifest.json b/homeassistant/components/litterrobot/manifest.json index 9a3334cbaac..ea096a908fc 100644 --- a/homeassistant/components/litterrobot/manifest.json +++ b/homeassistant/components/litterrobot/manifest.json @@ -12,5 +12,5 @@ "integration_type": "hub", "iot_class": "cloud_push", "loggers": ["pylitterbot"], - "requirements": ["pylitterbot==2023.4.5"] + "requirements": ["pylitterbot==2023.4.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 36e566cb26e..c4f974dc67a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1830,7 +1830,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8cff8948762..8f9ab13e49f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1376,7 +1376,7 @@ pylibrespot-java==0.1.1 pylitejet==0.5.0 # homeassistant.components.litterrobot -pylitterbot==2023.4.5 +pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta pylutron-caseta==0.18.2 From d0700db7eb2445d2d85a070ddd12adc528f10c91 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 2 Oct 2023 14:23:13 -0500 Subject: [PATCH 132/968] Bump intents to 2023.10.2 (#101277) --- homeassistant/components/conversation/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/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 2f733ead486..f11dda15a4e 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.9.22"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 659caa1078d..db28fbc4ac6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20230928.0 -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 httpx==0.24.1 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index c4f974dc67a..d7057ead555 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1000,7 +1000,7 @@ holidays==0.28 home-assistant-frontend==20230928.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f9ab13e49f..1848ba28c86 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -789,7 +789,7 @@ holidays==0.28 home-assistant-frontend==20230928.0 # homeassistant.components.conversation -home-assistant-intents==2023.9.22 +home-assistant-intents==2023.10.2 # homeassistant.components.home_connect homeconnect==0.7.2 From 2f0ba154b954ae185400b1820a5d7eb634bff8c4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 21:49:10 +0200 Subject: [PATCH 133/968] Update ruff to v0.0.292 (#101290) --- .pre-commit-config.yaml | 2 +- homeassistant/components/unifi/controller.py | 2 +- pyproject.toml | 7 ++++++- requirements_test_pre_commit.txt | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b0c98143300..82e8be48db3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.289 + rev: v0.0.292 hooks: - id: ruff args: diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 620b928176e..fc803e3d800 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -302,7 +302,7 @@ class UniFiController: self.poe_command_queue.clear() for device_id, device_commands in queue.items(): device = self.api.devices[device_id] - commands = [(idx, mode) for idx, mode in device_commands.items()] + commands = list(device_commands.items()) await self.api.request( DeviceSetPoePortModeRequest.create(device, targets=commands) ) diff --git a/pyproject.toml b/pyproject.toml index 6d6d3d126c8..3fb5e28dedf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -594,7 +594,12 @@ ignore = [ "D407", # Section name underlining "E501", # line too long "E731", # do not assign a lambda expression, use a def - "PLC1901", # Lots of false positives + + # Ignore ignored, as the rule is now back in preview/nursery, which cannot + # be ignored anymore without warnings. + # https://github.com/astral-sh/ruff/issues/7491 + # "PLC1901", # Lots of false positives + # False positives https://github.com/astral-sh/ruff/issues/5386 "PLC0208", # Use a sequence type instead of a `set` when iterating over values "PLR0911", # Too many return statements ({returns} > {max_returns}) diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index dadc3e0cab2..f3780898ea7 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,5 +2,5 @@ black==23.9.1 codespell==2.2.2 -ruff==0.0.289 +ruff==0.0.292 yamllint==1.32.0 From ca2f45d466b9fb3d4f3634d8e1a407d1b1748cdb Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 2 Oct 2023 23:30:58 +0300 Subject: [PATCH 134/968] Fix Shelly typo in cover platform (#101292) --- homeassistant/components/shelly/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py index 3d3e5be5b91..95f387f8f97 100644 --- a/homeassistant/components/shelly/cover.py +++ b/homeassistant/components/shelly/cover.py @@ -25,7 +25,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up switches for device.""" + """Set up covers for device.""" if get_device_entry_gen(config_entry) == 2: return async_setup_rpc_entry(hass, config_entry, async_add_entities) From 49e69aff0cb8ea88979c891ad49e0be78535c5a7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 2 Oct 2023 22:56:50 +0200 Subject: [PATCH 135/968] Update frontend to 20231002.0 (#101294) --- 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 9f01fadb710..40339e955f9 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==20230928.0"] + "requirements": ["home-assistant-frontend==20231002.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index db28fbc4ac6..eaba1eb6508 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 home-assistant-intents==2023.10.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index d7057ead555..68175c7f65e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1848ba28c86..f28530f6a68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230928.0 +home-assistant-frontend==20231002.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 From 44acc88365248080f9af224949dc4d978167a7da Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 2 Oct 2023 23:01:30 +0200 Subject: [PATCH 136/968] Update Lokalise CLI to v2.6.8 (#101297) --- script/translations/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/const.py b/script/translations/const.py index 7c50b7db5e3..ef8e3f2df74 100644 --- a/script/translations/const.py +++ b/script/translations/const.py @@ -3,6 +3,6 @@ import pathlib CORE_PROJECT_ID = "130246255a974bd3b5e8a1.51616605" FRONTEND_PROJECT_ID = "3420425759f6d6d241f598.13594006" -CLI_2_DOCKER_IMAGE = "2.5.1" +CLI_2_DOCKER_IMAGE = "v2.6.8" INTEGRATIONS_DIR = pathlib.Path("homeassistant/components") FRONTEND_DIR = pathlib.Path("../frontend") From 69e588b15e60631c2252b9d1c2345ffaf0cbc679 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 3 Oct 2023 10:10:18 +0200 Subject: [PATCH 137/968] Bump actions/setup-python from 4.7.0 to 4.7.1 (#101306) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 24 ++++++++++++------------ .github/workflows/translations.yml | 2 +- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 20d158ed676..98c3bfebbfc 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -59,7 +59,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -124,7 +124,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1e81bed2965..4a7ad03f218 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -225,7 +225,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -269,7 +269,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -337,7 +337,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -386,7 +386,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -481,7 +481,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -549,7 +549,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -581,7 +581,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -614,7 +614,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -658,7 +658,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -740,7 +740,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -892,7 +892,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1016,7 +1016,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index 84d7fc03e43..c35263b3df5 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4.1.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.7.0 + uses: actions/setup-python@v4.7.1 with: python-version: ${{ env.DEFAULT_PYTHON }} From 99f227229e49e5497b26205d3f444367c333da2a Mon Sep 17 00:00:00 2001 From: Aaron Collins Date: Tue, 3 Oct 2023 21:11:21 +1300 Subject: [PATCH 138/968] Remove duplicated device before daikin migration (#99900) Co-authored-by: Erik Montnemery --- homeassistant/components/daikin/__init__.py | 31 +++++- tests/components/daikin/test_init.py | 105 +++++++++++++++++++- 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index f6fd399f855..eda7976e572 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -135,9 +135,11 @@ async def async_migrate_unique_id( ) -> None: """Migrate old entry.""" dev_reg = dr.async_get(hass) + ent_reg = er.async_get(hass) old_unique_id = config_entry.unique_id new_unique_id = api.device.mac - new_name = api.device.values.get("name") + new_mac = dr.format_mac(new_unique_id) + new_name = api.name @callback def _update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: @@ -147,15 +149,36 @@ async def async_migrate_unique_id( if new_unique_id == old_unique_id: return + duplicate = dev_reg.async_get_device( + connections={(CONNECTION_NETWORK_MAC, new_mac)}, identifiers=None + ) + + # Remove duplicated device + if duplicate is not None: + if config_entry.entry_id in duplicate.config_entries: + _LOGGER.debug( + "Removing duplicated device %s", + duplicate.name, + ) + + # The automatic cleanup in entity registry is scheduled as a task, remove + # the entities manually to avoid unique_id collision when the entities + # are migrated. + duplicate_entities = er.async_entries_for_device( + ent_reg, duplicate.id, True + ) + for entity in duplicate_entities: + ent_reg.async_remove(entity.entity_id) + + dev_reg.async_remove_device(duplicate.id) + # Migrate devices for device_entry in dr.async_entries_for_config_entry( dev_reg, config_entry.entry_id ): for connection in device_entry.connections: if connection[1] == old_unique_id: - new_connections = { - (CONNECTION_NETWORK_MAC, dr.format_mac(new_unique_id)) - } + new_connections = {(CONNECTION_NETWORK_MAC, new_mac)} _LOGGER.debug( "Migrating device %s connections to %s", diff --git a/tests/components/daikin/test_init.py b/tests/components/daikin/test_init.py index a6a58b4fb39..3b5f81ae2e5 100644 --- a/tests/components/daikin/test_init.py +++ b/tests/components/daikin/test_init.py @@ -1,11 +1,13 @@ """Define tests for the Daikin init.""" import asyncio +from datetime import timedelta from unittest.mock import AsyncMock, PropertyMock, patch from aiohttp import ClientConnectionError +from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.daikin import update_unique_id +from homeassistant.components.daikin import DaikinApi, update_unique_id from homeassistant.components.daikin.const import DOMAIN, KEY_MAC from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST @@ -14,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .test_config_flow import HOST, MAC -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture @@ -28,6 +30,7 @@ def mock_daikin(): with patch("homeassistant.components.daikin.Appliance") as Appliance: Appliance.factory.side_effect = mock_daikin_factory type(Appliance).update_status = AsyncMock() + type(Appliance).device_ip = PropertyMock(return_value=HOST) type(Appliance).inside_temperature = PropertyMock(return_value=22) type(Appliance).target_temperature = PropertyMock(return_value=22) type(Appliance).zones = PropertyMock(return_value=[("Zone 1", "0", 0)]) @@ -47,6 +50,67 @@ DATA = { INVALID_DATA = {**DATA, "name": None, "mac": HOST} +async def test_duplicate_removal(hass: HomeAssistant, mock_daikin) -> None: + """Test duplicate device removal.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HOST, + title=None, + data={CONF_HOST: HOST, KEY_MAC: HOST}, + ) + config_entry.add_to_hass(hass) + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=HOST) + type(mock_daikin).values = PropertyMock(return_value=INVALID_DATA) + + with patch( + "homeassistant.components.daikin.async_migrate_unique_id", return_value=None + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + assert config_entry.unique_id != MAC + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name + == "DaikinAP00000" + ) + + assert device_registry.async_get_device({}, {(KEY_MAC, HOST)}).name is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == HOST + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith( + HOST + ) + + assert entity_registry.async_get("climate.daikinap00000").unique_id == MAC + assert entity_registry.async_get( + "switch.daikinap00000_zone_1" + ).unique_id.startswith(MAC) + + assert await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + device_registry.async_get_device({}, {(KEY_MAC, MAC)}).name == "DaikinAP00000" + ) + + assert entity_registry.async_get("climate.daikinap00000") is None + assert entity_registry.async_get("switch.daikinap00000_zone_1") is None + + assert entity_registry.async_get("climate.daikin_127_0_0_1").unique_id == MAC + assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) + + async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: """Test unique id migration.""" config_entry = MockConfigEntry( @@ -97,8 +161,41 @@ async def test_unique_id_migrate(hass: HomeAssistant, mock_daikin) -> None: assert entity_registry.async_get("switch.none_zone_1").unique_id.startswith(MAC) +async def test_client_update_connection_error( + hass: HomeAssistant, mock_daikin, freezer: FrozenDateTimeFactory +) -> None: + """Test client connection error on update.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=MAC, + data={CONF_HOST: HOST, KEY_MAC: MAC}, + ) + config_entry.add_to_hass(hass) + er.async_get(hass) + + type(mock_daikin).mac = PropertyMock(return_value=MAC) + type(mock_daikin).values = PropertyMock(return_value=DATA) + + await hass.config_entries.async_setup(config_entry.entry_id) + + api: DaikinApi = hass.data[DOMAIN][config_entry.entry_id] + + assert api.available is True + + type(mock_daikin).update_status.side_effect = ClientConnectionError + + freezer.tick(timedelta(seconds=90)) + async_fire_time_changed(hass) + + await hass.async_block_till_done() + + assert api.available is False + + assert mock_daikin.update_status.call_count == 2 + + async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test client connection error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, @@ -114,7 +211,7 @@ async def test_client_connection_error(hass: HomeAssistant, mock_daikin) -> None async def test_timeout_error(hass: HomeAssistant, mock_daikin) -> None: - """Test unique id migration.""" + """Test timeout error on setup.""" config_entry = MockConfigEntry( domain=DOMAIN, unique_id=MAC, From 727074a1a672a172d5d58a57a97433495af62191 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Tue, 3 Oct 2023 19:52:01 +1000 Subject: [PATCH 139/968] Revert PR #99077 for Aussie Broadband (#101314) --- .../components/aussie_broadband/__init__.py | 20 ++--------------- .../components/aussie_broadband/test_init.py | 22 ------------------- 2 files changed, 2 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 1bdb0579976..6fc4a4dd4d1 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -3,11 +3,10 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from aiohttp import ClientError from aussiebb.asyncio import AussieBB -from aussiebb.const import FETCH_TYPES, NBN_TYPES, PHONE_TYPES +from aussiebb.const import FETCH_TYPES from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType from homeassistant.config_entries import ConfigEntry @@ -23,19 +22,6 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -# Backport for the pyaussiebb=0.0.15 validate_service_type method -def validate_service_type(service: dict[str, Any]) -> None: - """Check the service types against known types.""" - - if "type" not in service: - raise ValueError("Field 'type' not found in service data") - if service["type"] not in NBN_TYPES + PHONE_TYPES + ["Hardware"]: - raise UnrecognisedServiceType( - f"Service type {service['type']=} {service['name']=} - not recognised - ", - "please report this at https://github.com/yaleman/aussiebb/issues/new", - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Aussie Broadband from a config entry.""" # Login to the Aussie Broadband API and retrieve the current service list @@ -44,9 +30,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_PASSWORD], async_get_clientsession(hass), ) - # Overwrite the pyaussiebb=0.0.15 validate_service_type method with backport - # Required until pydantic 2.x is supported - client.validate_service_type = validate_service_type + try: await client.login() services = await client.get_services(drop_types=FETCH_TYPES) diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index dc32212ee87..3eb1972011c 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -3,11 +3,8 @@ from unittest.mock import patch from aiohttp import ClientConnectionError from aussiebb.exceptions import AuthenticationException, UnrecognisedServiceType -import pydantic -import pytest from homeassistant import data_entry_flow -from homeassistant.components.aussie_broadband import validate_service_type from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -22,19 +19,6 @@ async def test_unload(hass: HomeAssistant) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def test_validate_service_type() -> None: - """Testing the validation function.""" - test_service = {"type": "Hardware", "name": "test service"} - validate_service_type(test_service) - - with pytest.raises(ValueError): - test_service = {"name": "test service"} - validate_service_type(test_service) - with pytest.raises(UnrecognisedServiceType): - test_service = {"type": "FunkyBob", "name": "test service"} - validate_service_type(test_service) - - async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( @@ -55,9 +39,3 @@ async def test_service_failure(hass: HomeAssistant) -> None: """Test init with a invalid service.""" entry = await setup_platform(hass, usage_effect=UnrecognisedServiceType()) assert entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_not_pydantic2() -> None: - """Test that Home Assistant still does not support Pydantic 2.""" - """For PR#99077 and validate_service_type backport""" - assert pydantic.__version__ < "2" From 7c7f00a46d92efdb09ddaa02e9bf460a93dcb22c Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 3 Oct 2023 12:17:23 +0200 Subject: [PATCH 140/968] Bump pyW800rf32 to 0.4 (#101317) bump pyW800rf32 from 0.3 to 0.4 --- homeassistant/components/w800rf32/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json index e76835abcbe..769eb96b3c0 100644 --- a/homeassistant/components/w800rf32/manifest.json +++ b/homeassistant/components/w800rf32/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/w800rf32", "iot_class": "local_push", "loggers": ["W800rf32"], - "requirements": ["pyW800rf32==0.1"] + "requirements": ["pyW800rf32==0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68175c7f65e..bee08b2680b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1565,7 +1565,7 @@ pyTibber==0.28.2 pyW215==0.7.0 # homeassistant.components.w800rf32 -pyW800rf32==0.1 +pyW800rf32==0.4 # homeassistant.components.ads pyads==3.2.2 From 135570acab5bdcb0ec56e0dc00b428d676b71074 Mon Sep 17 00:00:00 2001 From: Daniel Rheinbay Date: Tue, 3 Oct 2023 14:53:01 +0200 Subject: [PATCH 141/968] Add tea time effect to Yeelight (#95936) --- homeassistant/components/yeelight/light.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index a442540109a..c5cd6f906f5 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -105,6 +105,7 @@ EFFECT_SUNSET = "Sunset" EFFECT_ROMANCE = "Romance" EFFECT_HAPPY_BIRTHDAY = "Happy Birthday" EFFECT_CANDLE_FLICKER = "Candle Flicker" +EFFECT_TEA_TIME = "Tea Time" YEELIGHT_TEMP_ONLY_EFFECT_LIST = [EFFECT_TEMP, EFFECT_STOP] @@ -118,6 +119,7 @@ YEELIGHT_MONO_EFFECT_LIST = [ EFFECT_TWITTER, EFFECT_HOME, EFFECT_CANDLE_FLICKER, + EFFECT_TEA_TIME, *YEELIGHT_TEMP_ONLY_EFFECT_LIST, ] @@ -162,6 +164,7 @@ EFFECTS_MAP = { EFFECT_ROMANCE: flows.romance, EFFECT_HAPPY_BIRTHDAY: flows.happy_birthday, EFFECT_CANDLE_FLICKER: flows.candle_flicker, + EFFECT_TEA_TIME: flows.tea_time, } VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Range(min=1, max=100)) From d518cf13e52d6ebcdbc7c9471cb3ca41d230b4a5 Mon Sep 17 00:00:00 2001 From: Robert Groot <8398505+iamrgroot@users.noreply.github.com> Date: Tue, 3 Oct 2023 16:21:23 +0200 Subject: [PATCH 142/968] Add Energyzero get_prices service (#100499) --- .../components/energyzero/__init__.py | 74 +- homeassistant/components/energyzero/const.py | 17 + .../components/energyzero/services.yaml | 25 + .../components/energyzero/strings.json | 24 + tests/components/energyzero/conftest.py | 35 +- .../energyzero/snapshots/test_services.ambr | 1051 +++++++++++++++++ tests/components/energyzero/test_services.py | 51 + 7 files changed, 1266 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/energyzero/services.yaml create mode 100644 tests/components/energyzero/snapshots/test_services.ambr create mode 100644 tests/components/energyzero/test_services.py diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 096e312efc0..85c76c30371 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -1,17 +1,74 @@ """The EnergyZero integration.""" from __future__ import annotations +from datetime import date, datetime + +from energyzero import Electricity, EnergyZero, Gas + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import dt as dt_util -from .const import DOMAIN +from .const import ( + ATTR_END, + ATTR_INCL_VAT, + ATTR_START, + ATTR_TYPE, + DOMAIN, + SERVICE_NAME, + SERVICE_SCHEMA, +) from .coordinator import EnergyZeroDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] +def _get_date(date_input: str | None) -> date | datetime: + """Get date.""" + if not date_input: + return dt_util.now().date() + + if value := dt_util.parse_datetime(date_input): + return value + + raise ValueError(f"Invalid date: {date_input}") + + +def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: + """Serialize prices.""" + return {str(timestamp): price for timestamp, price in prices.prices.items()} + + +async def _get_prices(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse: + """Search prices.""" + price_type = call.data[ATTR_TYPE] + + energyzero = EnergyZero( + session=async_get_clientsession(hass), + incl_btw=str(call.data[ATTR_INCL_VAT]).lower(), + ) + + start = _get_date(call.data.get(ATTR_START)) + end = _get_date(call.data.get(ATTR_END)) + + if price_type == "energy": + return __serialize_prices( + await energyzero.energy_prices(start_date=start, end_date=end) + ) + + return __serialize_prices( + await energyzero.gas_prices(start_date=start, end_date=end) + ) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EnergyZero from a config entry.""" @@ -25,6 +82,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + async def get_prices(call: ServiceCall) -> ServiceResponse: + """Search prices.""" + return await _get_prices(hass, call) + + hass.services.async_register( + DOMAIN, + SERVICE_NAME, + get_prices, + schema=SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) + return True diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index 03d94facf3b..f1b69fc8789 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -5,12 +5,29 @@ from datetime import timedelta import logging from typing import Final +import voluptuous as vol + DOMAIN: Final = "energyzero" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=10) THRESHOLD_HOUR: Final = 14 +ATTR_TYPE: Final = "type" +ATTR_START: Final = "start" +ATTR_END: Final = "end" +ATTR_INCL_VAT: Final = "incl_vat" + SERVICE_TYPE_DEVICE_NAMES = { "today_energy": "Energy market price", "today_gas": "Gas market price", } +SERVICE_NAME: Final = "get_prices" +SERVICE_PRICE_TYPES: Final = ["energy", "gas"] +SERVICE_SCHEMA: Final = vol.Schema( + { + vol.Required(ATTR_TYPE): vol.In(SERVICE_PRICE_TYPES), + vol.Optional(ATTR_START): str, + vol.Optional(ATTR_END): str, + vol.Optional(ATTR_INCL_VAT, default=True): bool, + } +) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml new file mode 100644 index 00000000000..f8b0b7c5099 --- /dev/null +++ b/homeassistant/components/energyzero/services.yaml @@ -0,0 +1,25 @@ +get_prices: + fields: + type: + required: true + example: "gas" + selector: + select: + options: + - "gas" + - "energy" + incl_vat: + required: false + example: false + selector: + boolean: + start: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: + end: + required: false + example: "2023-01-01 00:00:00" + selector: + datetime: diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index a27ce236c28..c1b603465dd 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -39,5 +39,29 @@ "name": "Hours priced equal or lower than current - today" } } + }, + "services": { + "get_prices": { + "name": "Get prices", + "description": "Request energy or gas prices from EnergyZero.", + "fields": { + "type": { + "name": "Type", + "description": "Type of prices to get, energy or gas." + }, + "incl_vat": { + "name": "Including VAT", + "description": "Include VAT in the prices. Defaults to true if omitted." + }, + "start": { + "name": "Start", + "description": "From which moment to get the prices. Defaults to today if omitted." + }, + "end": { + "name": "End", + "description": "Until which moment to get the prices. Defaults to today if omitted." + } + } + } } } diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index 42b05eff444..bf2b1ccb396 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -32,25 +32,42 @@ def mock_config_entry() -> MockConfigEntry: ) +def apply_energyzero_mock(energyzero_mock): + """Apply mocks to EnergyZero client.""" + client = energyzero_mock.return_value + client.energy_prices.return_value = Electricity.from_dict( + json.loads(load_fixture("today_energy.json", DOMAIN)) + ) + client.gas_prices.return_value = Gas.from_dict( + json.loads(load_fixture("today_gas.json", DOMAIN)) + ) + return client + + @pytest.fixture def mock_energyzero() -> Generator[MagicMock, None, None]: """Return a mocked EnergyZero client.""" with patch( "homeassistant.components.energyzero.coordinator.EnergyZero", autospec=True ) as energyzero_mock: - client = energyzero_mock.return_value - client.energy_prices.return_value = Electricity.from_dict( - json.loads(load_fixture("today_energy.json", DOMAIN)) - ) - client.gas_prices.return_value = Gas.from_dict( - json.loads(load_fixture("today_gas.json", DOMAIN)) - ) - yield client + yield apply_energyzero_mock(energyzero_mock) + + +@pytest.fixture +def mock_energyzero_service() -> Generator[MagicMock, None, None]: + """Return a mocked EnergyZero client.""" + with patch( + "homeassistant.components.energyzero.EnergyZero", autospec=True + ) as energyzero_mock: + yield apply_energyzero_mock(energyzero_mock) @pytest.fixture async def init_integration( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_energyzero: MagicMock + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_energyzero: MagicMock, + mock_energyzero_service: MagicMock, ) -> MockConfigEntry: """Set up the EnergyZero integration for testing.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr new file mode 100644 index 00000000000..ca84a5113be --- /dev/null +++ b/tests/components/energyzero/snapshots/test_services.ambr @@ -0,0 +1,1051 @@ +# serializer version: 1 +# name: test_service[end0-start0-incl_vat0-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start0-incl_vat0-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end0-start0-incl_vat1-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start0-incl_vat1-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end0-start0-incl_vat2-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start0-incl_vat2-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end0-start1-incl_vat0-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start1-incl_vat0-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start1-incl_vat1-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start1-incl_vat1-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start1-incl_vat2-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start1-incl_vat2-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end0-start2-incl_vat0-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start2-incl_vat0-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end0-start2-incl_vat1-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start2-incl_vat1-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end0-start2-incl_vat2-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end0-start2-incl_vat2-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end1-start0-incl_vat0-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start0-incl_vat0-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start0-incl_vat1-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start0-incl_vat1-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start0-incl_vat2-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start0-incl_vat2-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat0-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat0-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat1-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat1-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat2-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start1-incl_vat2-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat0-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat0-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat1-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat1-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat2-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end1-start2-incl_vat2-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start0-incl_vat0-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start0-incl_vat0-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end2-start0-incl_vat1-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start0-incl_vat1-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end2-start0-incl_vat2-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start0-incl_vat2-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end2-start1-incl_vat0-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start1-incl_vat0-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start1-incl_vat1-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start1-incl_vat1-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start1-incl_vat2-price_type0] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start1-incl_vat2-price_type1] + ValueError('Invalid date: incorrect date') +# --- +# name: test_service[end2-start2-incl_vat0-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start2-incl_vat0-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end2-start2-incl_vat1-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start2-incl_vat1-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- +# name: test_service[end2-start2-incl_vat2-price_type0] + dict({ + '2022-12-05 23:00:00+00:00': 1.43, + '2022-12-06 00:00:00+00:00': 1.43, + '2022-12-06 01:00:00+00:00': 1.43, + '2022-12-06 02:00:00+00:00': 1.43, + '2022-12-06 03:00:00+00:00': 1.43, + '2022-12-06 04:00:00+00:00': 1.43, + '2022-12-06 05:00:00+00:00': 1.45, + '2022-12-06 06:00:00+00:00': 1.45, + '2022-12-06 07:00:00+00:00': 1.45, + '2022-12-06 08:00:00+00:00': 1.45, + '2022-12-06 09:00:00+00:00': 1.45, + '2022-12-06 10:00:00+00:00': 1.45, + '2022-12-06 11:00:00+00:00': 1.45, + '2022-12-06 12:00:00+00:00': 1.45, + '2022-12-06 13:00:00+00:00': 1.45, + '2022-12-06 14:00:00+00:00': 1.45, + '2022-12-06 15:00:00+00:00': 1.45, + '2022-12-06 16:00:00+00:00': 1.45, + '2022-12-06 17:00:00+00:00': 1.45, + '2022-12-06 18:00:00+00:00': 1.45, + '2022-12-06 19:00:00+00:00': 1.45, + '2022-12-06 20:00:00+00:00': 1.45, + '2022-12-06 21:00:00+00:00': 1.45, + '2022-12-06 22:00:00+00:00': 1.45, + '2022-12-06 23:00:00+00:00': 1.45, + '2022-12-07 00:00:00+00:00': 1.45, + '2022-12-07 01:00:00+00:00': 1.45, + '2022-12-07 02:00:00+00:00': 1.45, + '2022-12-07 03:00:00+00:00': 1.45, + '2022-12-07 04:00:00+00:00': 1.45, + '2022-12-07 05:00:00+00:00': 1.47, + '2022-12-07 06:00:00+00:00': 1.47, + '2022-12-07 07:00:00+00:00': 1.47, + '2022-12-07 08:00:00+00:00': 1.47, + '2022-12-07 09:00:00+00:00': 1.47, + '2022-12-07 10:00:00+00:00': 1.47, + '2022-12-07 11:00:00+00:00': 1.47, + '2022-12-07 12:00:00+00:00': 1.47, + '2022-12-07 13:00:00+00:00': 1.47, + '2022-12-07 14:00:00+00:00': 1.47, + '2022-12-07 15:00:00+00:00': 1.47, + '2022-12-07 16:00:00+00:00': 1.47, + '2022-12-07 17:00:00+00:00': 1.47, + '2022-12-07 18:00:00+00:00': 1.47, + '2022-12-07 19:00:00+00:00': 1.47, + '2022-12-07 20:00:00+00:00': 1.47, + '2022-12-07 21:00:00+00:00': 1.47, + '2022-12-07 22:00:00+00:00': 1.47, + }) +# --- +# name: test_service[end2-start2-incl_vat2-price_type1] + dict({ + '2022-12-06 23:00:00+00:00': 0.35, + '2022-12-07 00:00:00+00:00': 0.32, + '2022-12-07 01:00:00+00:00': 0.28, + '2022-12-07 02:00:00+00:00': 0.26, + '2022-12-07 03:00:00+00:00': 0.27, + '2022-12-07 04:00:00+00:00': 0.28, + '2022-12-07 05:00:00+00:00': 0.28, + '2022-12-07 06:00:00+00:00': 0.38, + '2022-12-07 07:00:00+00:00': 0.41, + '2022-12-07 08:00:00+00:00': 0.46, + '2022-12-07 09:00:00+00:00': 0.44, + '2022-12-07 10:00:00+00:00': 0.39, + '2022-12-07 11:00:00+00:00': 0.33, + '2022-12-07 12:00:00+00:00': 0.37, + '2022-12-07 13:00:00+00:00': 0.44, + '2022-12-07 14:00:00+00:00': 0.48, + '2022-12-07 15:00:00+00:00': 0.49, + '2022-12-07 16:00:00+00:00': 0.55, + '2022-12-07 17:00:00+00:00': 0.37, + '2022-12-07 18:00:00+00:00': 0.4, + '2022-12-07 19:00:00+00:00': 0.4, + '2022-12-07 20:00:00+00:00': 0.32, + '2022-12-07 21:00:00+00:00': 0.33, + '2022-12-07 22:00:00+00:00': 0.31, + }) +# --- diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py new file mode 100644 index 00000000000..9e1fa6f3b98 --- /dev/null +++ b/tests/components/energyzero/test_services.py @@ -0,0 +1,51 @@ +"""Tests for the sensors provided by the EnergyZero integration.""" + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.energyzero.const import DOMAIN, SERVICE_NAME +from homeassistant.core import HomeAssistant + +pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] + + +@pytest.mark.usefixtures("init_integration") +async def test_has_service( + hass: HomeAssistant, +) -> None: + """Test the existence of the EnergyZero Service.""" + assert hass.services.has_service(DOMAIN, SERVICE_NAME) + + +@pytest.mark.usefixtures("init_integration") +@pytest.mark.parametrize("price_type", [{"type": "gas"}, {"type": "energy"}]) +@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}, {}]) +@pytest.mark.parametrize( + "start", [{"start": "2023-01-01 00:00:00"}, {"start": "incorrect date"}, {}] +) +@pytest.mark.parametrize( + "end", [{"end": "2023-01-01 00:00:00"}, {"end": "incorrect date"}, {}] +) +async def test_service( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + price_type: dict[str, str], + incl_vat: dict[str, bool], + start: dict[str, str], + end: dict[str, str], +) -> None: + """Test the EnergyZero Service.""" + + data = price_type | incl_vat | start | end + + try: + response = await hass.services.async_call( + DOMAIN, + SERVICE_NAME, + data, + blocking=True, + return_response=True, + ) + assert response == snapshot + except ValueError as e: + assert e == snapshot From bdcc6c0143cb443a03fb9cb2c5828886f0088e5e Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 4 Oct 2023 00:35:26 +1000 Subject: [PATCH 143/968] Fix reference error in Aussie Broadband (#101315) --- homeassistant/components/aussie_broadband/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/aussie_broadband/__init__.py b/homeassistant/components/aussie_broadband/__init__.py index 6fc4a4dd4d1..093480afd7d 100644 --- a/homeassistant/components/aussie_broadband/__init__.py +++ b/homeassistant/components/aussie_broadband/__init__.py @@ -45,10 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: return await client.get_usage(service_id) except UnrecognisedServiceType as err: - raise UpdateFailed( - f"Service {service_id} of type '{services[service_id]['type']}' was" - " unrecognised" - ) from err + raise UpdateFailed(f"Service {service_id} was unrecognised") from err return async_update_data From 956098ae3afca2944b37c1d7e9b335b7d02567be Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 3 Oct 2023 19:21:31 +0300 Subject: [PATCH 144/968] Shelly - remove unused device update info call (#101295) --- homeassistant/components/shelly/coordinator.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 1a8081b2053..d838b5c6547 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -295,8 +295,6 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except InvalidAuthError: self.entry.async_start_reauth(self.hass) - else: - device_update_info(self.hass, self.device, self.entry) @callback def _async_handle_update( From ab2de18f8f0ecf431c43a366305c55a49cb15247 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Oct 2023 19:21:27 +0200 Subject: [PATCH 145/968] Refactor frame.get_integration_frame (#101322) --- homeassistant/helpers/deprecation.py | 10 ++-- homeassistant/helpers/frame.py | 38 +++++++----- tests/conftest.py | 27 --------- tests/helpers/test_deprecation.py | 86 +++++++++++++++++++++++++++- tests/helpers/test_frame.py | 62 +++++++++++++++----- 5 files changed, 163 insertions(+), 60 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 08803aaded6..307a297272c 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -132,24 +132,24 @@ def deprecated_function( def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> None: logger = logging.getLogger(obj.__module__) try: - _, integration, path = get_integration_frame() - if path == "custom_components/": + integration_frame = get_integration_frame() + if integration_frame.custom_integration: logger.warning( ( "%s was called from %s, this is a deprecated %s. Use %s instead," " please report this to the maintainer of %s" ), obj.__name__, - integration, + integration_frame.integration, description, replacement, - integration, + integration_frame.integration, ) else: logger.warning( "%s was called from %s, this is a deprecated %s. Use %s instead", obj.__name__, - integration, + integration_frame.integration, description, replacement, ) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 988db411a6b..084a781bf62 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from dataclasses import dataclass import functools import logging from traceback import FrameSummary, extract_stack @@ -18,9 +19,17 @@ _REPORTED_INTEGRATIONS: set[str] = set() _CallableT = TypeVar("_CallableT", bound=Callable) -def get_integration_frame( - exclude_integrations: set | None = None, -) -> tuple[FrameSummary, str, str]: +@dataclass +class IntegrationFrame: + """Integration frame container.""" + + custom_integration: bool + filename: str + frame: FrameSummary + integration: str + + +def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: """Return the frame, integration and integration path of the current stack frame.""" found_frame = None if not exclude_integrations: @@ -46,7 +55,12 @@ def get_integration_frame( if found_frame is None: raise MissingIntegrationFrame - return found_frame, integration, path + return IntegrationFrame( + path == "custom_components/", + found_frame.filename[index:], + found_frame, + integration, + ) class MissingIntegrationFrame(HomeAssistantError): @@ -74,28 +88,26 @@ def report( _LOGGER.warning(msg, stack_info=True) return - report_integration(what, integration_frame, level) + _report_integration(what, integration_frame, level) -def report_integration( +def _report_integration( what: str, - integration_frame: tuple[FrameSummary, str, str], + integration_frame: IntegrationFrame, level: int = logging.WARNING, ) -> None: """Report incorrect usage in an integration. Async friendly. """ - found_frame, integration, path = integration_frame - + found_frame = integration_frame.frame # Keep track of integrations already reported to prevent flooding key = f"{found_frame.filename}:{found_frame.lineno}" if key in _REPORTED_INTEGRATIONS: return _REPORTED_INTEGRATIONS.add(key) - index = found_frame.filename.index(path) - if path == "custom_components/": + if integration_frame.custom_integration: extra = " to the custom integration author" else: extra = "" @@ -108,8 +120,8 @@ def report_integration( ), what, extra, - integration, - found_frame.filename[index:], + integration_frame.integration, + integration_frame.filename, found_frame.lineno, (found_frame.line or "?").strip(), ) diff --git a/tests/conftest.py b/tests/conftest.py index f743a2fe96a..015cae17205 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1515,33 +1515,6 @@ async def recorder_mock( return await async_setup_recorder_instance(hass, recorder_config) -@pytest.fixture -def mock_integration_frame() -> Generator[Mock, None, None]: - """Mock as if we're calling code from inside an integration.""" - correct_frame = Mock( - filename="/home/paulus/homeassistant/components/hue/light.py", - lineno="23", - line="self.light.is_on", - ) - with patch( - "homeassistant.helpers.frame.extract_stack", - return_value=[ - Mock( - filename="/home/paulus/homeassistant/core.py", - lineno="23", - line="do_something()", - ), - correct_frame, - Mock( - filename="/home/paulus/aiohue/lights.py", - lineno="2", - line="something()", - ), - ], - ): - yield correct_frame - - @pytest.fixture(name="enable_bluetooth") async def mock_enable_bluetooth( hass: HomeAssistant, diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 380801123b0..1128f7d43c6 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -1,5 +1,5 @@ """Test deprecation helpers.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch import pytest @@ -87,7 +87,10 @@ def test_config_get_deprecated_new(mock_get_logger) -> None: def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: - """Test deprecated_function decorator.""" + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is not known. + """ @deprecated_function("new_function") def mock_deprecated_function(): @@ -98,3 +101,82 @@ def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: "mock_deprecated_function is a deprecated function. Use new_function instead" in caplog.text ) + + +def test_deprecated_function_called_from_built_in_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is built-in. + """ + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead" in caplog.text + ) + + +def test_deprecated_function_called_from_custom_integration( + caplog: pytest.LogCaptureFixture, +) -> None: + """Test deprecated_function decorator. + + This tests the behavior when the calling integration is custom. + """ + + @deprecated_function("new_function") + def mock_deprecated_function(): + pass + + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + Mock( + filename="/home/paulus/config/custom_components/hue/light.py", + lineno="23", + line="await session.close()", + ), + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + mock_deprecated_function() + assert ( + "mock_deprecated_function was called from hue, this is a deprecated function. " + "Use new_function instead, please report this to the maintainer of hue" + in caplog.text + ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 3086bebe09d..53d799a0400 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,5 +1,6 @@ """Test the frame helper.""" +from collections.abc import Generator from unittest.mock import Mock, patch import pytest @@ -7,15 +8,41 @@ import pytest from homeassistant.helpers import frame +@pytest.fixture +def mock_integration_frame() -> Generator[Mock, None, None]: + """Mock as if we're calling code from inside an integration.""" + correct_frame = Mock( + filename="/home/paulus/homeassistant/components/hue/light.py", + lineno="23", + line="self.light.is_on", + ) + with patch( + "homeassistant.helpers.frame.extract_stack", + return_value=[ + Mock( + filename="/home/paulus/homeassistant/core.py", + lineno="23", + line="do_something()", + ), + correct_frame, + Mock( + filename="/home/paulus/aiohue/lights.py", + lineno="2", + line="something()", + ), + ], + ): + yield correct_frame + + async def test_extract_frame_integration( caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test extracting the current frame from integration context.""" - found_frame, integration, path = frame.get_integration_frame() - - assert integration == "hue" - assert path == "homeassistant/components/" - assert found_frame == mock_integration_frame + integration_frame = frame.get_integration_frame() + assert integration_frame == frame.IntegrationFrame( + False, "homeassistant/components/hue/light.py", mock_integration_frame, "hue" + ) async def test_extract_frame_integration_with_excluded_integration( @@ -48,13 +75,13 @@ async def test_extract_frame_integration_with_excluded_integration( ), ], ): - found_frame, integration, path = frame.get_integration_frame( + integration_frame = frame.get_integration_frame( exclude_integrations={"zeroconf"} ) - assert integration == "mdns" - assert path == "homeassistant/components/" - assert found_frame == correct_frame + assert integration_frame == frame.IntegrationFrame( + False, "homeassistant/components/mdns/light.py", correct_frame, "mdns" + ) async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> None: @@ -77,23 +104,32 @@ async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> frame.get_integration_frame() -@pytest.mark.usefixtures("mock_integration_frame") @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) -async def test_prevent_flooding(caplog: pytest.LogCaptureFixture) -> None: +async def test_prevent_flooding( + caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock +) -> None: """Test to ensure a report is only written once to the log.""" what = "accessed hi instead of hello" key = "/home/paulus/homeassistant/components/hue/light.py:23" + integration = "hue" + filename = "homeassistant/components/hue/light.py" + + expected_message = ( + f"Detected integration that {what}. Please report issue for {integration} using" + f" this method at {filename}, line " + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}" + ) frame.report(what, error_if_core=False) - assert what in caplog.text + assert expected_message in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 caplog.clear() frame.report(what, error_if_core=False) - assert what not in caplog.text + assert expected_message not in caplog.text assert key in frame._REPORTED_INTEGRATIONS assert len(frame._REPORTED_INTEGRATIONS) == 1 From d723a87ea290e885d76f12ced7f3b317f83f9106 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 3 Oct 2023 19:30:18 +0200 Subject: [PATCH 146/968] Update coverage to 7.3.2 (#101319) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 42f96b1f507..cc10efcd099 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt astroid==3.0.0 -coverage==7.3.1 +coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 From cc7e35e299e29314e3bb7a1ec425a315ba76c47b Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 3 Oct 2023 12:41:00 -0500 Subject: [PATCH 147/968] Increase pipeline timeout to 5 minutes (#101327) --- .../assist_pipeline/websocket_api.py | 4 ++-- .../snapshots/test_websocket.ambr | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index f57424223cf..798843ea6e3 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -30,8 +30,8 @@ from .pipeline import ( async_get_pipeline, ) -DEFAULT_TIMEOUT = 30 -DEFAULT_WAKE_WORD_TIMEOUT = 3 +DEFAULT_TIMEOUT = 60 * 5 # seconds +DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 044e7758eb2..7cecf9fed40 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -5,7 +5,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -86,7 +86,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -179,7 +179,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -359,7 +359,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -460,7 +460,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -491,7 +491,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -564,7 +564,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': 1, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -590,7 +590,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- @@ -640,7 +640,7 @@ 'pipeline': , 'runner_data': dict({ 'stt_binary_handler_id': None, - 'timeout': 30, + 'timeout': 300, }), }) # --- From 2627abb9ef4450ce78390c9dff0f5f4879141f10 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 3 Oct 2023 20:57:24 +0200 Subject: [PATCH 148/968] Improve test coverage of deprecation helper (#101335) --- tests/helpers/test_deprecation.py | 82 +++++++++++++++++++++---------- 1 file changed, 56 insertions(+), 26 deletions(-) diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 1128f7d43c6..9a24cda74dd 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -4,68 +4,82 @@ from unittest.mock import MagicMock, Mock, patch import pytest from homeassistant.helpers.deprecation import ( + deprecated_class, deprecated_function, deprecated_substitute, get_deprecated, ) -class MockBaseClass: +class MockBaseClassDeprecatedProperty: """Mock base class for deprecated testing.""" @property @deprecated_substitute("old_property") def new_property(self): """Test property to fetch.""" - raise NotImplementedError() - - -class MockDeprecatedClass(MockBaseClass): - """Mock deprecated class object.""" - - @property - def old_property(self): - """Test property to fetch.""" - return True - - -class MockUpdatedClass(MockBaseClass): - """Mock updated class object.""" - - @property - def new_property(self): - """Test property to fetch.""" - return True + return "default_new" @patch("logging.getLogger") def test_deprecated_substitute_old_class(mock_get_logger) -> None: """Test deprecated class object.""" + + class MockDeprecatedClass(MockBaseClassDeprecatedProperty): + """Mock deprecated class object.""" + + @property + def old_property(self): + """Test property to fetch.""" + return "old" + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_object = MockDeprecatedClass() - assert mock_object.new_property is True - assert mock_object.new_property is True + assert mock_object.new_property == "old" assert mock_logger.warning.called assert len(mock_logger.warning.mock_calls) == 1 +@patch("logging.getLogger") +def test_deprecated_substitute_default_class(mock_get_logger) -> None: + """Test deprecated class object.""" + + class MockDefaultClass(MockBaseClassDeprecatedProperty): + """Mock updated class object.""" + + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + mock_object = MockDefaultClass() + assert mock_object.new_property == "default_new" + assert not mock_logger.warning.called + + @patch("logging.getLogger") def test_deprecated_substitute_new_class(mock_get_logger) -> None: """Test deprecated class object.""" + + class MockUpdatedClass(MockBaseClassDeprecatedProperty): + """Mock updated class object.""" + + @property + def new_property(self): + """Test property to fetch.""" + return "new" + mock_logger = MagicMock() mock_get_logger.return_value = mock_logger mock_object = MockUpdatedClass() - assert mock_object.new_property is True - assert mock_object.new_property is True + assert mock_object.new_property == "new" assert not mock_logger.warning.called @patch("logging.getLogger") def test_config_get_deprecated_old(mock_get_logger) -> None: - """Test deprecated class object.""" + """Test deprecated config.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -77,7 +91,7 @@ def test_config_get_deprecated_old(mock_get_logger) -> None: @patch("logging.getLogger") def test_config_get_deprecated_new(mock_get_logger) -> None: - """Test deprecated class object.""" + """Test deprecated config.""" mock_logger = MagicMock() mock_get_logger.return_value = mock_logger @@ -86,6 +100,22 @@ def test_config_get_deprecated_new(mock_get_logger) -> None: assert not mock_logger.warning.called +@deprecated_class("homeassistant.blah.NewClass") +class MockDeprecatedClass: + """Mock class for deprecated testing.""" + + +@patch("logging.getLogger") +def test_deprecated_class(mock_get_logger) -> None: + """Test deprecated class.""" + mock_logger = MagicMock() + mock_get_logger.return_value = mock_logger + + MockDeprecatedClass() + assert mock_logger.warning.called + assert len(mock_logger.warning.mock_calls) == 1 + + def test_deprecated_function(caplog: pytest.LogCaptureFixture) -> None: """Test deprecated_function decorator. From 1c60597e4170e27ac3c3b680b3c353660411537b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 3 Oct 2023 21:19:32 +0200 Subject: [PATCH 149/968] Make co2signal state attribute translatable (#101337) --- homeassistant/components/co2signal/strings.json | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 7dbcd2e7966..26976decdfc 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -34,10 +34,20 @@ "entity": { "sensor": { "carbon_intensity": { - "name": "CO2 intensity" + "name": "CO2 intensity", + "state_attributes": { + "country_code": { + "name": "Country code" + } + } }, "fossil_fuel_percentage": { - "name": "Grid fossil fuel percentage" + "name": "Grid fossil fuel percentage", + "state_attributes": { + "country_code": { + "name": "[%key:component::co2signal::entity::sensor::carbon_intensity::state_attributes::country_code::name%]" + } + } } } } From d8f1023210e460a92620fdd9ee4ceb97ffc0bb3e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 3 Oct 2023 20:07:17 +0000 Subject: [PATCH 150/968] Use `entity_registry_enabled_by_default` fixture in the NextDNS tests (#101339) --- tests/components/nextdns/test_sensor.py | 194 +----------------------- 1 file changed, 8 insertions(+), 186 deletions(-) diff --git a/tests/components/nextdns/test_sensor.py b/tests/components/nextdns/test_sensor.py index c3c7577bd83..e500ff3c626 100644 --- a/tests/components/nextdns/test_sensor.py +++ b/tests/components/nextdns/test_sensor.py @@ -4,12 +4,7 @@ from unittest.mock import patch from nextdns import ApiError -from homeassistant.components.nextdns.const import DOMAIN -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, - SensorStateClass, -) +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,158 +15,12 @@ from . import DNSSEC, ENCRYPTION, IP_VERSIONS, PROTOCOLS, STATUS, init_integrati from tests.common import async_fire_time_changed -async def test_sensor(hass: HomeAssistant) -> None: +async def test_sensor( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test states of sensors.""" registry = er.async_get(hass) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries", - suggested_object_id="fake_profile_dns_over_https_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh3_queries", - suggested_object_id="fake_profile_dns_over_http_3_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries_ratio", - suggested_object_id="fake_profile_dns_over_https_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh3_queries_ratio", - suggested_object_id="fake_profile_dns_over_http_3_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doq_queries", - suggested_object_id="fake_profile_dns_over_quic_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doq_queries_ratio", - suggested_object_id="fake_profile_dns_over_quic_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_dot_queries", - suggested_object_id="fake_profile_dns_over_tls_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_dot_queries_ratio", - suggested_object_id="fake_profile_dns_over_tls_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_not_validated_queries", - suggested_object_id="fake_profile_dnssec_not_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries", - suggested_object_id="fake_profile_dnssec_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries_ratio", - suggested_object_id="fake_profile_dnssec_validated_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries", - suggested_object_id="fake_profile_encrypted_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries_ratio", - suggested_object_id="fake_profile_encrypted_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv4_queries", - suggested_object_id="fake_profile_ipv4_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv6_queries", - suggested_object_id="fake_profile_ipv6_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv6_queries_ratio", - suggested_object_id="fake_profile_ipv6_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_tcp_queries", - suggested_object_id="fake_profile_tcp_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_tcp_queries_ratio", - suggested_object_id="fake_profile_tcp_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_udp_queries", - suggested_object_id="fake_profile_udp_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_udp_queries_ratio", - suggested_object_id="fake_profile_udp_queries_ratio", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_unencrypted_queries", - suggested_object_id="fake_profile_unencrypted_queries", - disabled_by=None, - ) - await init_integration(hass) state = hass.states.get("sensor.fake_profile_dns_queries") @@ -425,38 +274,11 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry.unique_id == "xyz12_udp_queries_ratio" -async def test_availability(hass: HomeAssistant) -> None: +async def test_availability( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Ensure that we mark the entities unavailable correctly when service causes an error.""" - registry = er.async_get(hass) - - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_doh_queries", - suggested_object_id="fake_profile_dns_over_https_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_validated_queries", - suggested_object_id="fake_profile_dnssec_validated_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_encrypted_queries", - suggested_object_id="fake_profile_encrypted_queries", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "xyz12_ipv4_queries", - suggested_object_id="fake_profile_ipv4_queries", - disabled_by=None, - ) + er.async_get(hass) await init_integration(hass) From e6504218bc14734cc87201ac31a652d85a510547 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 3 Oct 2023 16:52:31 -0500 Subject: [PATCH 151/968] Pipeline runs are only equal with same id (#101341) * Pipeline runs are only equal with same id * Use dict instead of list in PipelineRuns * Let it blow up * Test * Test rest of __eq__ --- .../components/assist_pipeline/pipeline.py | 21 +++++++++----- tests/components/assist_pipeline/test_init.py | 29 +++++++++++++++++++ 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 7e4c71671ad..76444fb2436 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -3,7 +3,7 @@ from __future__ import annotations import array import asyncio -from collections import deque +from collections import defaultdict, deque from collections.abc import AsyncGenerator, AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field from enum import StrEnum @@ -475,7 +475,7 @@ class PipelineRun: stt_provider: stt.SpeechToTextEntity | stt.Provider = field(init=False, repr=False) tts_engine: str = field(init=False, repr=False) tts_options: dict | None = field(init=False, default=None) - wake_word_entity_id: str = field(init=False, repr=False) + wake_word_entity_id: str | None = field(init=False, default=None, repr=False) wake_word_entity: wake_word.WakeWordDetectionEntity = field(init=False, repr=False) abort_wake_word_detection: bool = field(init=False, default=False) @@ -518,6 +518,13 @@ class PipelineRun: self.audio_settings.noise_suppression_level, ) + def __eq__(self, other: Any) -> bool: + """Compare pipeline runs by id.""" + if isinstance(other, PipelineRun): + return self.id == other.id + + return False + @callback def process_event(self, event: PipelineEvent) -> None: """Log an event and call listener.""" @@ -1565,21 +1572,19 @@ class PipelineRuns: def __init__(self, pipeline_store: PipelineStorageCollection) -> None: """Initialize.""" - self._pipeline_runs: dict[str, list[PipelineRun]] = {} + self._pipeline_runs: dict[str, dict[str, PipelineRun]] = defaultdict(dict) self._pipeline_store = pipeline_store pipeline_store.async_add_listener(self._change_listener) def add_run(self, pipeline_run: PipelineRun) -> None: """Add pipeline run.""" pipeline_id = pipeline_run.pipeline.id - if pipeline_id not in self._pipeline_runs: - self._pipeline_runs[pipeline_id] = [] - self._pipeline_runs[pipeline_id].append(pipeline_run) + self._pipeline_runs[pipeline_id][pipeline_run.id] = pipeline_run def remove_run(self, pipeline_run: PipelineRun) -> None: """Remove pipeline run.""" pipeline_id = pipeline_run.pipeline.id - self._pipeline_runs[pipeline_id].remove(pipeline_run) + self._pipeline_runs[pipeline_id].pop(pipeline_run.id) async def _change_listener( self, change_type: str, item_id: str, change: dict @@ -1589,7 +1594,7 @@ class PipelineRuns: return if pipeline_runs := self._pipeline_runs.get(item_id): # Create a temporary list in case the list is modified while we iterate - for pipeline_run in list(pipeline_runs): + for pipeline_run in list(pipeline_runs.values()): pipeline_run.abort_wake_word_detection = True diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 5258736c89f..98ecae628f1 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -627,3 +627,32 @@ async def test_wake_word_detection_aborted( await pipeline_input.execute() assert process_events(events) == snapshot + + +def test_pipeline_run_equality(hass: HomeAssistant, init_components) -> None: + """Test that pipeline run equality uses unique id.""" + + def event_callback(event): + pass + + pipeline = assist_pipeline.pipeline.async_get_pipeline(hass) + run_1 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + run_2 = assist_pipeline.pipeline.PipelineRun( + hass, + context=Context(), + pipeline=pipeline, + start_stage=assist_pipeline.PipelineStage.STT, + end_stage=assist_pipeline.PipelineStage.TTS, + event_callback=event_callback, + ) + + assert run_1 == run_1 + assert run_1 != run_2 + assert run_1 != 1234 From 63946175eabfc1ef10d86dfd13f0afa14442cb93 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Oct 2023 12:15:56 +1300 Subject: [PATCH 152/968] Fix manual stopping of the voice assistant pipeline (#101351) --- homeassistant/components/esphome/manager.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index f9f24128e2a..dfd7376f4f4 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -330,17 +330,17 @@ class ESPHomeManager: return None hass = self.hass - voice_assistant_udp_server = VoiceAssistantUDPServer( + self.voice_assistant_udp_server = VoiceAssistantUDPServer( hass, self.entry_data, self._handle_pipeline_event, self._handle_pipeline_finished, ) - port = await voice_assistant_udp_server.start_server() + port = await self.voice_assistant_udp_server.start_server() assert self.device_id is not None, "Device ID must be set" hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline( + self.voice_assistant_udp_server.run_pipeline( device_id=self.device_id, conversation_id=conversation_id or None, flags=flags, From 09ba34fb3ad7a46776f2f6b2920940a779f962ad Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 4 Oct 2023 13:09:12 +1300 Subject: [PATCH 153/968] Allow esphome device to disable vad on stream (#101352) --- homeassistant/components/esphome/voice_assistant.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index dc36b7475c4..8fba4bfb39a 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -260,6 +260,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): noise_suppression_level=audio_settings.noise_suppression_level, auto_gain_dbfs=audio_settings.auto_gain, volume_multiplier=audio_settings.volume_multiplier, + is_vad_enabled=bool(flags & VoiceAssistantCommandFlag.USE_VAD), ), ) From 5551a345ea898e2d8e3abd2e9570b4cec5428182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 4 Oct 2023 04:55:00 +0300 Subject: [PATCH 154/968] Remove some unnecessary uses of regular expressions (#101182) --- homeassistant/components/ifttt/alarm_control_panel.py | 3 +-- homeassistant/components/manual/alarm_control_panel.py | 3 +-- homeassistant/components/manual_mqtt/alarm_control_panel.py | 3 +-- homeassistant/components/mqtt/alarm_control_panel.py | 5 +---- homeassistant/helpers/config_validation.py | 5 +---- script/lint_and_test.py | 3 +-- 6 files changed, 6 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 8bd267891a6..a0b87bd4932 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations import logging -import re import voluptuous as vol @@ -160,7 +159,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return CodeFormat.NUMBER return CodeFormat.TEXT diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index da77aea6c4a..099275a98a1 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime import logging -import re from typing import Any import voluptuous as vol @@ -280,7 +279,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 69cd1ef3d11..d1442a4e9ed 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -3,7 +3,6 @@ from __future__ import annotations import datetime import logging -import re from typing import Any import voluptuous as vol @@ -347,7 +346,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Return one or more digits/characters.""" if self._code is None: return None - if isinstance(self._code, str) and re.search("^\\d+$", self._code): + if isinstance(self._code, str) and self._code.isdigit(): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 3600d9663dd..dddf8986ca0 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -3,7 +3,6 @@ from __future__ import annotations import functools import logging -import re import voluptuous as vol @@ -178,9 +177,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if (code := self._config.get(CONF_CODE)) is None: self._attr_code_format = None - elif code == REMOTE_CODE or ( - isinstance(code, str) and re.search("^\\d+$", code) - ): + elif code == REMOTE_CODE or (isinstance(code, str) and code.isdigit()): self._attr_code_format = alarm.CodeFormat.NUMBER else: self._attr_code_format = alarm.CodeFormat.TEXT diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index eed57e7ea25..18445ba0789 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -203,12 +203,9 @@ def boolean(value: Any) -> bool: raise vol.Invalid(f"invalid boolean value {value}") -_WS = re.compile("\\s*") - - def whitespace(value: Any) -> str: """Validate result contains only whitespace.""" - if isinstance(value, str) and _WS.fullmatch(value): + if isinstance(value, str) and (value == "" or value.isspace()): return value raise vol.Invalid(f"contains non-whitespace: {value}") diff --git a/script/lint_and_test.py b/script/lint_and_test.py index 27963758415..ee37841b056 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -173,8 +173,7 @@ async def main(): ) return - pyfile = re.compile(r".+\.py$") - pyfiles = [file for file in files if pyfile.match(file)] + pyfiles = [file for file in files if file.endswith(".py")] print("=============================") printc("bold", "CHANGED FILES:\n", "\n ".join(pyfiles)) From 3be3593ffae6a9cdfb8de7c4ee5222dc44af4121 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 04:00:30 +0200 Subject: [PATCH 155/968] Revert "Add Energyzero get_prices service (#100499)" (#101332) This reverts commit d518cf13e52d6ebcdbc7c9471cb3ca41d230b4a5. --- .../components/energyzero/__init__.py | 74 +- homeassistant/components/energyzero/const.py | 17 - .../components/energyzero/services.yaml | 25 - .../components/energyzero/strings.json | 24 - tests/components/energyzero/conftest.py | 35 +- .../energyzero/snapshots/test_services.ambr | 1051 ----------------- tests/components/energyzero/test_services.py | 51 - 7 files changed, 11 insertions(+), 1266 deletions(-) delete mode 100644 homeassistant/components/energyzero/services.yaml delete mode 100644 tests/components/energyzero/snapshots/test_services.ambr delete mode 100644 tests/components/energyzero/test_services.py diff --git a/homeassistant/components/energyzero/__init__.py b/homeassistant/components/energyzero/__init__.py index 85c76c30371..096e312efc0 100644 --- a/homeassistant/components/energyzero/__init__.py +++ b/homeassistant/components/energyzero/__init__.py @@ -1,74 +1,17 @@ """The EnergyZero integration.""" from __future__ import annotations -from datetime import date, datetime - -from energyzero import Electricity, EnergyZero, Gas - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import ( - HomeAssistant, - ServiceCall, - ServiceResponse, - SupportsResponse, -) +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import dt as dt_util -from .const import ( - ATTR_END, - ATTR_INCL_VAT, - ATTR_START, - ATTR_TYPE, - DOMAIN, - SERVICE_NAME, - SERVICE_SCHEMA, -) +from .const import DOMAIN from .coordinator import EnergyZeroDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -def _get_date(date_input: str | None) -> date | datetime: - """Get date.""" - if not date_input: - return dt_util.now().date() - - if value := dt_util.parse_datetime(date_input): - return value - - raise ValueError(f"Invalid date: {date_input}") - - -def __serialize_prices(prices: Electricity | Gas) -> ServiceResponse: - """Serialize prices.""" - return {str(timestamp): price for timestamp, price in prices.prices.items()} - - -async def _get_prices(hass: HomeAssistant, call: ServiceCall) -> ServiceResponse: - """Search prices.""" - price_type = call.data[ATTR_TYPE] - - energyzero = EnergyZero( - session=async_get_clientsession(hass), - incl_btw=str(call.data[ATTR_INCL_VAT]).lower(), - ) - - start = _get_date(call.data.get(ATTR_START)) - end = _get_date(call.data.get(ATTR_END)) - - if price_type == "energy": - return __serialize_prices( - await energyzero.energy_prices(start_date=start, end_date=end) - ) - - return __serialize_prices( - await energyzero.gas_prices(start_date=start, end_date=end) - ) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up EnergyZero from a config entry.""" @@ -82,19 +25,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - async def get_prices(call: ServiceCall) -> ServiceResponse: - """Search prices.""" - return await _get_prices(hass, call) - - hass.services.async_register( - DOMAIN, - SERVICE_NAME, - get_prices, - schema=SERVICE_SCHEMA, - supports_response=SupportsResponse.ONLY, - ) - return True diff --git a/homeassistant/components/energyzero/const.py b/homeassistant/components/energyzero/const.py index f1b69fc8789..03d94facf3b 100644 --- a/homeassistant/components/energyzero/const.py +++ b/homeassistant/components/energyzero/const.py @@ -5,29 +5,12 @@ from datetime import timedelta import logging from typing import Final -import voluptuous as vol - DOMAIN: Final = "energyzero" LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(minutes=10) THRESHOLD_HOUR: Final = 14 -ATTR_TYPE: Final = "type" -ATTR_START: Final = "start" -ATTR_END: Final = "end" -ATTR_INCL_VAT: Final = "incl_vat" - SERVICE_TYPE_DEVICE_NAMES = { "today_energy": "Energy market price", "today_gas": "Gas market price", } -SERVICE_NAME: Final = "get_prices" -SERVICE_PRICE_TYPES: Final = ["energy", "gas"] -SERVICE_SCHEMA: Final = vol.Schema( - { - vol.Required(ATTR_TYPE): vol.In(SERVICE_PRICE_TYPES), - vol.Optional(ATTR_START): str, - vol.Optional(ATTR_END): str, - vol.Optional(ATTR_INCL_VAT, default=True): bool, - } -) diff --git a/homeassistant/components/energyzero/services.yaml b/homeassistant/components/energyzero/services.yaml deleted file mode 100644 index f8b0b7c5099..00000000000 --- a/homeassistant/components/energyzero/services.yaml +++ /dev/null @@ -1,25 +0,0 @@ -get_prices: - fields: - type: - required: true - example: "gas" - selector: - select: - options: - - "gas" - - "energy" - incl_vat: - required: false - example: false - selector: - boolean: - start: - required: false - example: "2023-01-01 00:00:00" - selector: - datetime: - end: - required: false - example: "2023-01-01 00:00:00" - selector: - datetime: diff --git a/homeassistant/components/energyzero/strings.json b/homeassistant/components/energyzero/strings.json index c1b603465dd..a27ce236c28 100644 --- a/homeassistant/components/energyzero/strings.json +++ b/homeassistant/components/energyzero/strings.json @@ -39,29 +39,5 @@ "name": "Hours priced equal or lower than current - today" } } - }, - "services": { - "get_prices": { - "name": "Get prices", - "description": "Request energy or gas prices from EnergyZero.", - "fields": { - "type": { - "name": "Type", - "description": "Type of prices to get, energy or gas." - }, - "incl_vat": { - "name": "Including VAT", - "description": "Include VAT in the prices. Defaults to true if omitted." - }, - "start": { - "name": "Start", - "description": "From which moment to get the prices. Defaults to today if omitted." - }, - "end": { - "name": "End", - "description": "Until which moment to get the prices. Defaults to today if omitted." - } - } - } } } diff --git a/tests/components/energyzero/conftest.py b/tests/components/energyzero/conftest.py index bf2b1ccb396..42b05eff444 100644 --- a/tests/components/energyzero/conftest.py +++ b/tests/components/energyzero/conftest.py @@ -32,42 +32,25 @@ def mock_config_entry() -> MockConfigEntry: ) -def apply_energyzero_mock(energyzero_mock): - """Apply mocks to EnergyZero client.""" - client = energyzero_mock.return_value - client.energy_prices.return_value = Electricity.from_dict( - json.loads(load_fixture("today_energy.json", DOMAIN)) - ) - client.gas_prices.return_value = Gas.from_dict( - json.loads(load_fixture("today_gas.json", DOMAIN)) - ) - return client - - @pytest.fixture def mock_energyzero() -> Generator[MagicMock, None, None]: """Return a mocked EnergyZero client.""" with patch( "homeassistant.components.energyzero.coordinator.EnergyZero", autospec=True ) as energyzero_mock: - yield apply_energyzero_mock(energyzero_mock) - - -@pytest.fixture -def mock_energyzero_service() -> Generator[MagicMock, None, None]: - """Return a mocked EnergyZero client.""" - with patch( - "homeassistant.components.energyzero.EnergyZero", autospec=True - ) as energyzero_mock: - yield apply_energyzero_mock(energyzero_mock) + client = energyzero_mock.return_value + client.energy_prices.return_value = Electricity.from_dict( + json.loads(load_fixture("today_energy.json", DOMAIN)) + ) + client.gas_prices.return_value = Gas.from_dict( + json.loads(load_fixture("today_gas.json", DOMAIN)) + ) + yield client @pytest.fixture async def init_integration( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_energyzero: MagicMock, - mock_energyzero_service: MagicMock, + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_energyzero: MagicMock ) -> MockConfigEntry: """Set up the EnergyZero integration for testing.""" mock_config_entry.add_to_hass(hass) diff --git a/tests/components/energyzero/snapshots/test_services.ambr b/tests/components/energyzero/snapshots/test_services.ambr deleted file mode 100644 index ca84a5113be..00000000000 --- a/tests/components/energyzero/snapshots/test_services.ambr +++ /dev/null @@ -1,1051 +0,0 @@ -# serializer version: 1 -# name: test_service[end0-start0-incl_vat0-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start0-incl_vat0-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end0-start0-incl_vat1-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start0-incl_vat1-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end0-start0-incl_vat2-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start0-incl_vat2-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end0-start1-incl_vat0-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start1-incl_vat0-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start1-incl_vat1-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start1-incl_vat1-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start1-incl_vat2-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start1-incl_vat2-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end0-start2-incl_vat0-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start2-incl_vat0-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end0-start2-incl_vat1-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start2-incl_vat1-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end0-start2-incl_vat2-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end0-start2-incl_vat2-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end1-start0-incl_vat0-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start0-incl_vat0-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start0-incl_vat1-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start0-incl_vat1-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start0-incl_vat2-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start0-incl_vat2-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat0-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat0-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat1-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat1-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat2-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start1-incl_vat2-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat0-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat0-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat1-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat1-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat2-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end1-start2-incl_vat2-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start0-incl_vat0-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start0-incl_vat0-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end2-start0-incl_vat1-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start0-incl_vat1-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end2-start0-incl_vat2-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start0-incl_vat2-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end2-start1-incl_vat0-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start1-incl_vat0-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start1-incl_vat1-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start1-incl_vat1-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start1-incl_vat2-price_type0] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start1-incl_vat2-price_type1] - ValueError('Invalid date: incorrect date') -# --- -# name: test_service[end2-start2-incl_vat0-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start2-incl_vat0-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end2-start2-incl_vat1-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start2-incl_vat1-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- -# name: test_service[end2-start2-incl_vat2-price_type0] - dict({ - '2022-12-05 23:00:00+00:00': 1.43, - '2022-12-06 00:00:00+00:00': 1.43, - '2022-12-06 01:00:00+00:00': 1.43, - '2022-12-06 02:00:00+00:00': 1.43, - '2022-12-06 03:00:00+00:00': 1.43, - '2022-12-06 04:00:00+00:00': 1.43, - '2022-12-06 05:00:00+00:00': 1.45, - '2022-12-06 06:00:00+00:00': 1.45, - '2022-12-06 07:00:00+00:00': 1.45, - '2022-12-06 08:00:00+00:00': 1.45, - '2022-12-06 09:00:00+00:00': 1.45, - '2022-12-06 10:00:00+00:00': 1.45, - '2022-12-06 11:00:00+00:00': 1.45, - '2022-12-06 12:00:00+00:00': 1.45, - '2022-12-06 13:00:00+00:00': 1.45, - '2022-12-06 14:00:00+00:00': 1.45, - '2022-12-06 15:00:00+00:00': 1.45, - '2022-12-06 16:00:00+00:00': 1.45, - '2022-12-06 17:00:00+00:00': 1.45, - '2022-12-06 18:00:00+00:00': 1.45, - '2022-12-06 19:00:00+00:00': 1.45, - '2022-12-06 20:00:00+00:00': 1.45, - '2022-12-06 21:00:00+00:00': 1.45, - '2022-12-06 22:00:00+00:00': 1.45, - '2022-12-06 23:00:00+00:00': 1.45, - '2022-12-07 00:00:00+00:00': 1.45, - '2022-12-07 01:00:00+00:00': 1.45, - '2022-12-07 02:00:00+00:00': 1.45, - '2022-12-07 03:00:00+00:00': 1.45, - '2022-12-07 04:00:00+00:00': 1.45, - '2022-12-07 05:00:00+00:00': 1.47, - '2022-12-07 06:00:00+00:00': 1.47, - '2022-12-07 07:00:00+00:00': 1.47, - '2022-12-07 08:00:00+00:00': 1.47, - '2022-12-07 09:00:00+00:00': 1.47, - '2022-12-07 10:00:00+00:00': 1.47, - '2022-12-07 11:00:00+00:00': 1.47, - '2022-12-07 12:00:00+00:00': 1.47, - '2022-12-07 13:00:00+00:00': 1.47, - '2022-12-07 14:00:00+00:00': 1.47, - '2022-12-07 15:00:00+00:00': 1.47, - '2022-12-07 16:00:00+00:00': 1.47, - '2022-12-07 17:00:00+00:00': 1.47, - '2022-12-07 18:00:00+00:00': 1.47, - '2022-12-07 19:00:00+00:00': 1.47, - '2022-12-07 20:00:00+00:00': 1.47, - '2022-12-07 21:00:00+00:00': 1.47, - '2022-12-07 22:00:00+00:00': 1.47, - }) -# --- -# name: test_service[end2-start2-incl_vat2-price_type1] - dict({ - '2022-12-06 23:00:00+00:00': 0.35, - '2022-12-07 00:00:00+00:00': 0.32, - '2022-12-07 01:00:00+00:00': 0.28, - '2022-12-07 02:00:00+00:00': 0.26, - '2022-12-07 03:00:00+00:00': 0.27, - '2022-12-07 04:00:00+00:00': 0.28, - '2022-12-07 05:00:00+00:00': 0.28, - '2022-12-07 06:00:00+00:00': 0.38, - '2022-12-07 07:00:00+00:00': 0.41, - '2022-12-07 08:00:00+00:00': 0.46, - '2022-12-07 09:00:00+00:00': 0.44, - '2022-12-07 10:00:00+00:00': 0.39, - '2022-12-07 11:00:00+00:00': 0.33, - '2022-12-07 12:00:00+00:00': 0.37, - '2022-12-07 13:00:00+00:00': 0.44, - '2022-12-07 14:00:00+00:00': 0.48, - '2022-12-07 15:00:00+00:00': 0.49, - '2022-12-07 16:00:00+00:00': 0.55, - '2022-12-07 17:00:00+00:00': 0.37, - '2022-12-07 18:00:00+00:00': 0.4, - '2022-12-07 19:00:00+00:00': 0.4, - '2022-12-07 20:00:00+00:00': 0.32, - '2022-12-07 21:00:00+00:00': 0.33, - '2022-12-07 22:00:00+00:00': 0.31, - }) -# --- diff --git a/tests/components/energyzero/test_services.py b/tests/components/energyzero/test_services.py deleted file mode 100644 index 9e1fa6f3b98..00000000000 --- a/tests/components/energyzero/test_services.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Tests for the sensors provided by the EnergyZero integration.""" - -import pytest -from syrupy.assertion import SnapshotAssertion - -from homeassistant.components.energyzero.const import DOMAIN, SERVICE_NAME -from homeassistant.core import HomeAssistant - -pytestmark = [pytest.mark.freeze_time("2022-12-07 15:00:00")] - - -@pytest.mark.usefixtures("init_integration") -async def test_has_service( - hass: HomeAssistant, -) -> None: - """Test the existence of the EnergyZero Service.""" - assert hass.services.has_service(DOMAIN, SERVICE_NAME) - - -@pytest.mark.usefixtures("init_integration") -@pytest.mark.parametrize("price_type", [{"type": "gas"}, {"type": "energy"}]) -@pytest.mark.parametrize("incl_vat", [{"incl_vat": False}, {"incl_vat": True}, {}]) -@pytest.mark.parametrize( - "start", [{"start": "2023-01-01 00:00:00"}, {"start": "incorrect date"}, {}] -) -@pytest.mark.parametrize( - "end", [{"end": "2023-01-01 00:00:00"}, {"end": "incorrect date"}, {}] -) -async def test_service( - hass: HomeAssistant, - snapshot: SnapshotAssertion, - price_type: dict[str, str], - incl_vat: dict[str, bool], - start: dict[str, str], - end: dict[str, str], -) -> None: - """Test the EnergyZero Service.""" - - data = price_type | incl_vat | start | end - - try: - response = await hass.services.async_call( - DOMAIN, - SERVICE_NAME, - data, - blocking=True, - return_response=True, - ) - assert response == snapshot - except ValueError as e: - assert e == snapshot From adf6d34d95a0325e9ebba664b77dfea7b3b75538 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 4 Oct 2023 05:01:45 +0100 Subject: [PATCH 156/968] Remove deprecated speed conversion functions (#101350) --- homeassistant/util/speed.py | 44 ------------------ tests/util/test_speed.py | 92 ------------------------------------- 2 files changed, 136 deletions(-) delete mode 100644 homeassistant/util/speed.py delete mode 100644 tests/util/test_speed.py diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py deleted file mode 100644 index 80a3609ab4d..00000000000 --- a/homeassistant/util/speed.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Distance util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - SPEED, - SPEED_FEET_PER_SECOND, - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_KILOMETERS_PER_HOUR, - SPEED_KNOTS, - SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import ( # noqa: F401 - _FOOT_TO_M as FOOT_TO_M, - _HRS_TO_SECS as HRS_TO_SECS, - _IN_TO_M as IN_TO_M, - _KM_TO_M as KM_TO_M, - _MILE_TO_M as MILE_TO_M, - _NAUTICAL_MILE_TO_M as NAUTICAL_MILE_TO_M, - SpeedConverter, -) - -# pylint: disable-next=protected-access -UNIT_CONVERSION: dict[str | None, float] = SpeedConverter._UNIT_CONVERSION -VALID_UNITS = SpeedConverter.VALID_UNITS - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses speed utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.SpeedConverter instead" - ), - error_if_core=False, - ) - return SpeedConverter.convert(value, from_unit, to_unit) diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py deleted file mode 100644 index ae47b4d39cc..00000000000 --- a/tests/util/test_speed.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Test Home Assistant speed utility functions.""" -import pytest - -from homeassistant.const import ( - SPEED_INCHES_PER_DAY, - SPEED_INCHES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, - UnitOfSpeed, -) -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.speed as speed_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfSpeed.KILOMETERS_PER_HOUR - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 - assert "use unit_conversion.SpeedConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert speed_util.convert(2, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_DAY) == 2 - assert speed_util.convert(3, SPEED_INCHES_PER_HOUR, SPEED_INCHES_PER_HOUR) == 3 - assert ( - speed_util.convert( - 4, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KILOMETERS_PER_HOUR - ) - == 4 - ) - assert ( - speed_util.convert( - 5, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.METERS_PER_SECOND - ) - == 5 - ) - assert ( - speed_util.convert(6, UnitOfSpeed.MILES_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR) - == 6 - ) - assert ( - speed_util.convert(7, SPEED_MILLIMETERS_PER_DAY, SPEED_MILLIMETERS_PER_DAY) == 7 - ) - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - speed_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - speed_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - speed_util.convert( - "a", UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.MILES_PER_HOUR - ) - - -@pytest.mark.parametrize( - ("from_value", "from_unit", "expected", "to_unit"), - [ - # 5 km/h / 1.609 km/mi = 3.10686 mi/h - (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.10686, UnitOfSpeed.MILES_PER_HOUR), - # 5 mi/h * 1.609 km/mi = 8.04672 km/h - (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), - # 5 in/day * 25.4 mm/in = 127 mm/day - (5, SPEED_INCHES_PER_DAY, 127, SPEED_MILLIMETERS_PER_DAY), - # 5 mm/day / 25.4 mm/in = 0.19685 in/day - (5, SPEED_MILLIMETERS_PER_DAY, 0.19685, SPEED_INCHES_PER_DAY), - # 5 in/hr * 24 hr/day = 3048 mm/day - (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), - # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 - (5, UnitOfSpeed.METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), - # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s - (5000, SPEED_INCHES_PER_HOUR, 0.03528, UnitOfSpeed.METERS_PER_SECOND), - # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s - (5, UnitOfSpeed.KNOTS, 2.5722, UnitOfSpeed.METERS_PER_SECOND), - # 5 ft/s * 0.3048 m/ft = 1.524 m/s - (5, UnitOfSpeed.FEET_PER_SECOND, 1.524, UnitOfSpeed.METERS_PER_SECOND), - ], -) -def test_convert_different_units(from_value, from_unit, expected, to_unit) -> None: - """Test conversion between units.""" - assert speed_util.convert(from_value, from_unit, to_unit) == pytest.approx( - expected, rel=1e-4 - ) From efca5ba554ff4416c751755228eedac89837c4be Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 4 Oct 2023 05:03:32 +0100 Subject: [PATCH 157/968] Remove deprecated pressure conversion functions (#101347) Remove deprecated pressure conversion utils --- docs/source/api/util.rst | 8 -- homeassistant/util/pressure.py | 37 --------- tests/util/test_pressure.py | 139 --------------------------------- 3 files changed, 184 deletions(-) delete mode 100644 homeassistant/util/pressure.py delete mode 100644 tests/util/test_pressure.py diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index f670fc52204..9310f2fcb7a 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -102,14 +102,6 @@ homeassistant.util.pil :undoc-members: :show-inheritance: -homeassistant.util.pressure ---------------------------- - -.. automodule:: homeassistant.util.pressure - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.ssl ---------------------- diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py deleted file mode 100644 index 9c5082e95ed..00000000000 --- a/homeassistant/util/pressure.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Pressure util functions.""" -from __future__ import annotations - -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - PRESSURE, - PRESSURE_BAR, - PRESSURE_CBAR, - PRESSURE_HPA, - PRESSURE_INHG, - PRESSURE_KPA, - PRESSURE_MBAR, - PRESSURE_MMHG, - PRESSURE_PA, - PRESSURE_PSI, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import PressureConverter - -# pylint: disable-next=protected-access -UNIT_CONVERSION: dict[str | None, float] = PressureConverter._UNIT_CONVERSION -VALID_UNITS = PressureConverter.VALID_UNITS - - -def convert(value: float, from_unit: str, to_unit: str) -> float: - """Convert one unit of measurement to another.""" - report( - ( - "uses pressure utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.PressureConverter instead" - ), - error_if_core=False, - ) - return PressureConverter.convert(value, from_unit, to_unit) diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py deleted file mode 100644 index 9bc8e56d78a..00000000000 --- a/tests/util/test_pressure.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Test Home Assistant pressure utility functions.""" -import pytest - -from homeassistant.const import UnitOfPressure -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.pressure as pressure_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfPressure.PA - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert pressure_util.convert(2, UnitOfPressure.PA, UnitOfPressure.PA) == 2 - assert "use unit_conversion.PressureConverter instead" in caplog.text - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert pressure_util.convert(2, UnitOfPressure.PA, UnitOfPressure.PA) == 2 - assert pressure_util.convert(3, UnitOfPressure.HPA, UnitOfPressure.HPA) == 3 - assert pressure_util.convert(4, UnitOfPressure.MBAR, UnitOfPressure.MBAR) == 4 - assert pressure_util.convert(5, UnitOfPressure.INHG, UnitOfPressure.INHG) == 5 - assert pressure_util.convert(6, UnitOfPressure.KPA, UnitOfPressure.KPA) == 6 - assert pressure_util.convert(7, UnitOfPressure.CBAR, UnitOfPressure.CBAR) == 7 - assert pressure_util.convert(8, UnitOfPressure.MMHG, UnitOfPressure.MMHG) == 8 - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - pressure_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - pressure_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - pressure_util.convert("a", UnitOfPressure.HPA, UnitOfPressure.INHG) - - -def test_convert_from_hpascals() -> None: - """Test conversion from hPA to other units.""" - hpascals = 1000 - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.PSI - ) == pytest.approx(14.5037743897) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.INHG - ) == pytest.approx(29.5299801647) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.PA - ) == pytest.approx(100000) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.KPA - ) == pytest.approx(100) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.MBAR - ) == pytest.approx(1000) - assert pressure_util.convert( - hpascals, UnitOfPressure.HPA, UnitOfPressure.CBAR - ) == pytest.approx(100) - - -def test_convert_from_kpascals() -> None: - """Test conversion from hPA to other units.""" - kpascals = 100 - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.PSI - ) == pytest.approx(14.5037743897) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.INHG - ) == pytest.approx(29.5299801647) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.PA - ) == pytest.approx(100000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.HPA - ) == pytest.approx(1000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.MBAR - ) == pytest.approx(1000) - assert pressure_util.convert( - kpascals, UnitOfPressure.KPA, UnitOfPressure.CBAR - ) == pytest.approx(100) - - -def test_convert_from_inhg() -> None: - """Test conversion from inHg to other units.""" - inhg = 30 - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.PSI - ) == pytest.approx(14.7346266155) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.KPA - ) == pytest.approx(101.59167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.HPA - ) == pytest.approx(1015.9167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.PA - ) == pytest.approx(101591.67) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.MBAR - ) == pytest.approx(1015.9167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.CBAR - ) == pytest.approx(101.59167) - assert pressure_util.convert( - inhg, UnitOfPressure.INHG, UnitOfPressure.MMHG - ) == pytest.approx(762) - - -def test_convert_from_mmhg() -> None: - """Test conversion from mmHg to other units.""" - inhg = 30 - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.PSI - ) == pytest.approx(0.580103) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.KPA - ) == pytest.approx(3.99967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.HPA - ) == pytest.approx(39.9967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.PA - ) == pytest.approx(3999.67) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.MBAR - ) == pytest.approx(39.9967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.CBAR - ) == pytest.approx(3.99967) - assert pressure_util.convert( - inhg, UnitOfPressure.MMHG, UnitOfPressure.INHG - ) == pytest.approx(1.181102) From 8626a4888c735d66f85cb78b6dfe042c2061065c Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 4 Oct 2023 05:04:23 +0100 Subject: [PATCH 158/968] Remove deprecated temperature conversion functions (#101204) --- docs/source/api/util.rst | 8 -- homeassistant/util/temperature.py | 51 ------------ tests/util/test_temperature.py | 128 ------------------------------ 3 files changed, 187 deletions(-) delete mode 100644 homeassistant/util/temperature.py delete mode 100644 tests/util/test_temperature.py diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst index 9310f2fcb7a..1ed4049c218 100644 --- a/docs/source/api/util.rst +++ b/docs/source/api/util.rst @@ -110,14 +110,6 @@ homeassistant.util.ssl :undoc-members: :show-inheritance: -homeassistant.util.temperature ------------------------------- - -.. automodule:: homeassistant.util.temperature - :members: - :undoc-members: - :show-inheritance: - homeassistant.util.unit\_system ------------------------------- diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py deleted file mode 100644 index 74d56e84d94..00000000000 --- a/homeassistant/util/temperature.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Temperature util functions.""" -# pylint: disable-next=hass-deprecated-import -from homeassistant.const import ( # noqa: F401 - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - TEMP_KELVIN, - TEMPERATURE, - UNIT_NOT_RECOGNIZED_TEMPLATE, -) -from homeassistant.helpers.frame import report - -from .unit_conversion import TemperatureConverter - -VALID_UNITS = TemperatureConverter.VALID_UNITS - - -def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float: - """Convert a temperature in Fahrenheit to Celsius.""" - return convert(fahrenheit, TEMP_FAHRENHEIT, TEMP_CELSIUS, interval) - - -def kelvin_to_celsius(kelvin: float, interval: bool = False) -> float: - """Convert a temperature in Kelvin to Celsius.""" - return convert(kelvin, TEMP_KELVIN, TEMP_CELSIUS, interval) - - -def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float: - """Convert a temperature in Celsius to Fahrenheit.""" - return convert(celsius, TEMP_CELSIUS, TEMP_FAHRENHEIT, interval) - - -def celsius_to_kelvin(celsius: float, interval: bool = False) -> float: - """Convert a temperature in Celsius to Fahrenheit.""" - return convert(celsius, TEMP_CELSIUS, TEMP_KELVIN, interval) - - -def convert( - temperature: float, from_unit: str, to_unit: str, interval: bool = False -) -> float: - """Convert a temperature from one unit to another.""" - report( - ( - "uses temperature utility. This is deprecated since 2022.10 and will " - "stop working in Home Assistant 2023.4, it should be updated to use " - "unit_conversion.TemperatureConverter instead" - ), - error_if_core=False, - ) - if interval: - return TemperatureConverter.convert_interval(temperature, from_unit, to_unit) - return TemperatureConverter.convert(temperature, from_unit, to_unit) diff --git a/tests/util/test_temperature.py b/tests/util/test_temperature.py deleted file mode 100644 index 93edb8f7393..00000000000 --- a/tests/util/test_temperature.py +++ /dev/null @@ -1,128 +0,0 @@ -"""Test Home Assistant temperature utility functions.""" -import pytest - -from homeassistant.const import UnitOfTemperature -from homeassistant.exceptions import HomeAssistantError -import homeassistant.util.temperature as temperature_util - -INVALID_SYMBOL = "bob" -VALID_SYMBOL = UnitOfTemperature.CELSIUS - - -def test_raise_deprecation_warning(caplog: pytest.LogCaptureFixture) -> None: - """Ensure that a warning is raised on use of convert.""" - assert ( - temperature_util.convert( - 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS - ) - == 2 - ) - assert "use unit_conversion.TemperatureConverter instead" in caplog.text - - -@pytest.mark.parametrize( - ("function_name", "value", "expected"), - [ - ("fahrenheit_to_celsius", 75.2, 24), - ("kelvin_to_celsius", 297.65, 24.5), - ("celsius_to_fahrenheit", 23, 73.4), - ("celsius_to_kelvin", 23, 296.15), - ], -) -def test_deprecated_functions( - function_name: str, value: float, expected: float -) -> None: - """Test that deprecated function still work.""" - convert = getattr(temperature_util, function_name) - assert convert(value) == expected - - -def test_convert_same_unit() -> None: - """Test conversion from any unit to same unit.""" - assert ( - temperature_util.convert( - 2, UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS - ) - == 2 - ) - assert ( - temperature_util.convert( - 3, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.FAHRENHEIT - ) - == 3 - ) - assert ( - temperature_util.convert(4, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN) - == 4 - ) - - -def test_convert_invalid_unit() -> None: - """Test exception is thrown for invalid units.""" - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - temperature_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL) - - with pytest.raises(HomeAssistantError, match="is not a recognized .* unit"): - temperature_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL) - - -def test_convert_nonnumeric_value() -> None: - """Test exception is thrown for nonnumeric type.""" - with pytest.raises(TypeError): - temperature_util.convert( - "a", UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) - - -def test_convert_from_celsius() -> None: - """Test conversion from C to other units.""" - celsius = 100 - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT - ) == pytest.approx(212.0) - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN - ) == pytest.approx(373.15) - # Interval - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT, True - ) == pytest.approx(180.0) - assert temperature_util.convert( - celsius, UnitOfTemperature.CELSIUS, UnitOfTemperature.KELVIN, True - ) == pytest.approx(100) - - -def test_convert_from_fahrenheit() -> None: - """Test conversion from F to other units.""" - fahrenheit = 100 - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS - ) == pytest.approx(37.77777777777778) - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN - ) == pytest.approx(310.92777777777775) - # Interval - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.CELSIUS, True - ) == pytest.approx(55.55555555555556) - assert temperature_util.convert( - fahrenheit, UnitOfTemperature.FAHRENHEIT, UnitOfTemperature.KELVIN, True - ) == pytest.approx(55.55555555555556) - - -def test_convert_from_kelvin() -> None: - """Test conversion from K to other units.""" - kelvin = 100 - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.CELSIUS - ) == pytest.approx(-173.15) - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT - ) == pytest.approx(-279.66999999999996) - # Interval - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.FAHRENHEIT, True - ) == pytest.approx(180.0) - assert temperature_util.convert( - kelvin, UnitOfTemperature.KELVIN, UnitOfTemperature.KELVIN, True - ) == pytest.approx(100) From d14e5dc56a08f9e64c0d5bb53d1b58fbda4b30aa Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 4 Oct 2023 09:19:57 +0200 Subject: [PATCH 159/968] Increase update interval of update platform in devolo_home_network (#101366) Increase update interval of firmware platform --- homeassistant/components/devolo_home_network/__init__.py | 3 ++- homeassistant/components/devolo_home_network/const.py | 1 + tests/components/devolo_home_network/test_update.py | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index d76a6163516..94e848fe8af 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -35,6 +35,7 @@ from .const import ( CONNECTED_PLC_DEVICES, CONNECTED_WIFI_CLIENTS, DOMAIN, + FIRMWARE_UPDATE_INTERVAL, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, REGULAR_FIRMWARE, @@ -146,7 +147,7 @@ async def async_setup_entry( # noqa: C901 _LOGGER, name=REGULAR_FIRMWARE, update_method=async_update_firmware_available, - update_interval=LONG_UPDATE_INTERVAL, + update_interval=FIRMWARE_UPDATE_INTERVAL, ) if device.device and "wifi1" in device.device.features: coordinators[CONNECTED_WIFI_CLIENTS] = DataUpdateCoordinator( diff --git a/homeassistant/components/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index ba3f5e5b815..aaee8051cb5 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -14,6 +14,7 @@ PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" +FIRMWARE_UPDATE_INTERVAL = timedelta(hours=5) LONG_UPDATE_INTERVAL = timedelta(minutes=5) SHORT_UPDATE_INTERVAL = timedelta(seconds=15) diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 97d313d9273..cb6de649e8e 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.devolo_home_network.const import ( DOMAIN, - LONG_UPDATE_INTERVAL, + FIRMWARE_UPDATE_INTERVAL, ) from homeassistant.components.update import ( DOMAIN as PLATFORM, @@ -78,7 +78,7 @@ async def test_update_firmware( mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -106,7 +106,7 @@ async def test_device_failure_check( assert state is not None mock_device.device.async_check_firmware_available.side_effect = DeviceUnavailable - freezer.tick(LONG_UPDATE_INTERVAL) + freezer.tick(FIRMWARE_UPDATE_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() From f08d66741ddbc2cf2950b6845439577dfa365ad1 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 4 Oct 2023 03:40:03 -0400 Subject: [PATCH 160/968] Check that dock error status is not None for Roborock (#101321) Co-authored-by: Robert Resch --- homeassistant/components/roborock/sensor.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8a18c281d59..113e02e4abe 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import datetime from roborock.containers import ( RoborockDockErrorCode, @@ -38,7 +39,7 @@ from .device import RoborockCoordinatedEntity class RoborockSensorDescriptionMixin: """A class that describes sensor entities.""" - value_fn: Callable[[DeviceProp], int] + value_fn: Callable[[DeviceProp], StateType | datetime.datetime] @dataclass @@ -48,6 +49,15 @@ class RoborockSensorDescription( """A class that describes Roborock sensors.""" +def _dock_error_value_fn(properties: DeviceProp) -> str | None: + if ( + status := properties.status.dock_error_status + ) is not None and properties.status.dock_type != RoborockDockTypeCode.no_dock: + return status.name + + return None + + SENSOR_DESCRIPTIONS = [ RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -173,9 +183,7 @@ SENSOR_DESCRIPTIONS = [ key="dock_error", icon="mdi:garage-open", translation_key="dock_error", - value_fn=lambda data: data.status.dock_error_status.name - if data.status.dock_type != RoborockDockTypeCode.no_dock - else None, + value_fn=_dock_error_value_fn, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.ENUM, options=RoborockDockErrorCode.keys(), @@ -228,7 +236,7 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): self.entity_description = description @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime.datetime: """Return the value reported by the sensor.""" return self.entity_description.value_fn( self.coordinator.roborock_device_info.props From fb724472fbf310520956c2ecf7ff124ec1df98cd Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 4 Oct 2023 09:54:43 +0200 Subject: [PATCH 161/968] Update Pillow to 10.0.1 (#101368) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index bc7c7d97430..12397eb8990 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.0.0"] + "requirements": ["pydoods==1.0.2", "Pillow==10.0.1"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index a89ee370920..2966d668ac9 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.0.0"] + "requirements": ["ha-av==10.1.1", "Pillow==10.0.1"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index 4f139785cd3..b6c74f0c53c 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 74bb97d10fc..69d059fdce5 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.1"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index b38bc93567d..b5b25a66342 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index 2176aa0c91e..f1f40dd8973 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.0.0", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index ed8638d8419..2b730648e22 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.0.0"] + "requirements": ["Pillow==10.0.1"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index 33080a9c1a2..d1bc97da7a8 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.0.0", "simplehound==0.3"] + "requirements": ["Pillow==10.0.1", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index bfd3e77ee50..c8682941e28 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.0.0" + "Pillow==10.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index eaba1eb6508..51d03a40971 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ mutagen==1.47.0 orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.0.0 +Pillow==10.0.1 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index bee08b2680b..f0eb01784fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -43,7 +43,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex PlexAPI==4.15.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f28530f6a68..bbc2e766665 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.0 +Pillow==10.0.1 # homeassistant.components.plex PlexAPI==4.15.3 From cd175f679f654a4da06bc1c7b3042f83ef8d71cf Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Wed, 4 Oct 2023 18:05:44 +1000 Subject: [PATCH 162/968] Fix temperature when myZone is in use for Advantage air (#101316) --- homeassistant/components/advantage_air/climate.py | 7 +++++++ homeassistant/components/advantage_air/entity.py | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index fa9f609ba10..cda123f62ee 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -125,6 +125,13 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the current target temperature.""" + # If the system is in MyZone mode, and a zone is set, return that temperature instead. + if ( + self._ac["myZone"] > 0 + and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) + and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) + ): + return self._myzone["setTemp"] return self._ac["setTemp"] @property diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index 00750fb4e94..b300a677793 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -62,6 +62,12 @@ class AdvantageAirAcEntity(AdvantageAirEntity): def _ac(self) -> dict[str, Any]: return self.coordinator.data["aircons"][self.ac_key]["info"] + @property + def _myzone(self) -> dict[str, Any]: + return self.coordinator.data["aircons"][self.ac_key]["zones"].get( + f"z{self._ac['myZone']:02}" + ) + class AdvantageAirZoneEntity(AdvantageAirAcEntity): """Parent class for Advantage Air Zone Entities.""" From d3c5b9777b4dcbf82d28ec4ea90c31631231f325 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 4 Oct 2023 04:18:48 -0400 Subject: [PATCH 163/968] Notify users when zwave device gets reset (#101362) * Notify users when zwave device gets reset * review comments --- homeassistant/components/zwave_js/__init__.py | 42 +++++++++----- homeassistant/components/zwave_js/helpers.py | 13 +++++ tests/components/zwave_js/test_init.py | 58 +++++++++++++++++++ 3 files changed, 99 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index c9decc92a67..a8b3d300e3b 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -113,6 +113,7 @@ from .helpers import ( async_enable_statistics, get_device_id, get_device_id_ext, + get_network_identifier_for_notification, get_unique_id, get_valueless_base_unique_id, ) @@ -448,6 +449,28 @@ class ControllerEvents: "remove_entity" ), ) + elif reason == RemoveNodeReason.RESET: + device_name = device.name_by_user or device.name or f"Node {node.node_id}" + identifier = get_network_identifier_for_notification( + self.hass, self.config_entry, self.driver_events.driver.controller + ) + notification_msg = ( + f"`{device_name}` has been factory reset " + "and removed from the Z-Wave network" + ) + if identifier: + # Remove trailing comma if it's there + if identifier[-1] == ",": + identifier = identifier[:-1] + notification_msg = f"{notification_msg} {identifier}." + else: + notification_msg = f"{notification_msg}." + async_create( + self.hass, + notification_msg, + "Device Was Factory Reset!", + f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}", + ) else: self.remove_device(device) @@ -459,26 +482,17 @@ class ControllerEvents: dev_id = get_device_id(self.driver_events.driver, node) device = self.dev_reg.async_get_device(identifiers={dev_id}) assert device - device_name = device.name_by_user or device.name - home_id = self.driver_events.driver.controller.home_id - # We do this because we know at this point the controller has its home ID as - # as it is part of the device ID - assert home_id + device_name = device.name_by_user or device.name or f"Node {node.node_id}" # In case the user has multiple networks, we should give them more information # about the network for the controller being identified. - identifier = "" - if len(self.hass.config_entries.async_entries(DOMAIN)) > 1: - if str(home_id) != self.config_entry.title: - identifier = ( - f"`{self.config_entry.title}`, with the home ID `{home_id}`, " - ) - else: - identifier = f"with the home ID `{home_id}` " + identifier = get_network_identifier_for_notification( + self.hass, self.config_entry, self.driver_events.driver.controller + ) async_create( self.hass, ( f"`{device_name}` has just requested the controller for your Z-Wave " - f"network {identifier}to identify itself. No action is needed from " + f"network {identifier} to identify itself. No action is needed from " "you other than to note the source of the request, and you can safely " "dismiss this notification when ready." ), diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 8774bcea73f..5d78d3e57e7 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -14,6 +14,7 @@ from zwave_js_server.const import ( ConfigurationValueType, LogLevel, ) +from zwave_js_server.model.controller import Controller from zwave_js_server.model.driver import Driver from zwave_js_server.model.log_config import LogConfig from zwave_js_server.model.node import Node as ZwaveNode @@ -512,3 +513,15 @@ def get_device_info(driver: Driver, node: ZwaveNode) -> DeviceInfo: manufacturer=node.device_config.manufacturer, suggested_area=node.location if node.location else None, ) + + +def get_network_identifier_for_notification( + hass: HomeAssistant, config_entry: ConfigEntry, controller: Controller +) -> str: + """Return the network identifier string for persistent notifications.""" + home_id = str(controller.home_id) + if len(hass.config_entries.async_entries(DOMAIN)) > 1: + if str(home_id) != config_entry.title: + return f"`{config_entry.title}`, with the home ID `{home_id}`," + return f"with the home ID `{home_id}`" + return "" diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 1203997839e..c57e3b1f868 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1644,3 +1644,61 @@ async def test_server_logging(hass: HomeAssistant, client) -> None: assert len(client.async_send_command.call_args_list) == 0 assert not client.enable_server_logging.called assert not client.disable_server_logging.called + + +async def test_factory_reset_node( + hass: HomeAssistant, client, multisensor_6, multisensor_6_state, integration +) -> None: + """Test when a node is removed because it was reset.""" + # One config entry scenario + remove_event = Event( + type="node removed", + data={ + "source": "controller", + "event": "node removed", + "reason": 5, + "node": deepcopy(multisensor_6_state), + }, + ) + dev_id = get_device_id(client.driver, multisensor_6) + msg_id = f"{DOMAIN}.node_reset_and_removed.{dev_id[1]}" + + client.driver.controller.receive_event(remove_event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert notifications[msg_id]["message"].startswith("`Multisensor 6`") + assert "with the home ID" not in notifications[msg_id]["message"] + async_dismiss(hass, msg_id) + + # Add mock config entry to simulate having multiple entries + new_entry = MockConfigEntry(domain=DOMAIN) + new_entry.add_to_hass(hass) + + # Re-add the node then remove it again + client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( + client, deepcopy(multisensor_6_state) + ) + remove_event.data["node"] = deepcopy(multisensor_6_state) + client.driver.controller.receive_event(remove_event) + # Test case where config entry title and home ID don't match + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert ( + "network `Mock Title`, with the home ID `3245146787`." + in notifications[msg_id]["message"] + ) + async_dismiss(hass, msg_id) + + # Test case where config entry title and home ID do match + hass.config_entries.async_update_entry(integration, title="3245146787") + client.driver.controller.nodes[multisensor_6_state["nodeId"]] = Node( + client, deepcopy(multisensor_6_state) + ) + remove_event.data["node"] = deepcopy(multisensor_6_state) + client.driver.controller.receive_event(remove_event) + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + assert list(notifications)[0] == msg_id + assert "network with the home ID `3245146787`" in notifications[msg_id]["message"] From 3aa677183502b7c4f85bfcd99b8f34c316893411 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 4 Oct 2023 09:00:17 +0000 Subject: [PATCH 164/968] Update `DeviceInfo.sw_version` value for Shelly Gen2 sleeping devices (#101338) * Update device info for gen2 sleeping devices * Add test * Update sw_version only if the firmware_version value has changed * Rename device_update_info() to update_device_fw_info() * Remove duplicate comparison --- .../components/shelly/coordinator.py | 11 +++---- homeassistant/components/shelly/utils.py | 12 ++++--- tests/components/shelly/test_coordinator.py | 33 +++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d838b5c6547..e648a80420a 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -58,7 +58,7 @@ from .const import ( UPDATE_PERIOD_MULTIPLIER, BLEScannerMode, ) -from .utils import device_update_info, get_rpc_device_wakeup_period +from .utils import get_rpc_device_wakeup_period, update_device_fw_info _DeviceT = TypeVar("_DeviceT", bound="BlockDevice|RpcDevice") @@ -374,16 +374,13 @@ class ShellyRestCoordinator(ShellyCoordinatorBase[BlockDevice]): if self.device.status["uptime"] > 2 * REST_SENSORS_UPDATE_INTERVAL: return - old_firmware = self.device.firmware_version await self.device.update_shelly() - if self.device.firmware_version == old_firmware: - return except DeviceConnectionError as err: raise UpdateFailed(f"Error fetching data: {repr(err)}") from err except InvalidAuthError: self.entry.async_start_reauth(self.hass) else: - device_update_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.entry) class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): @@ -531,7 +528,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): LOGGER.debug("Reconnecting to Shelly RPC Device - %s", self.name) try: await self.device.initialize() - device_update_info(self.hass, self.device, self.entry) + update_device_fw_info(self.hass, self.device, self.entry) except DeviceConnectionError as err: raise UpdateFailed(f"Device disconnected: {repr(err)}") from err except InvalidAuthError: @@ -617,6 +614,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.hass.async_create_task(self._async_disconnected()) elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) + if self.sleep_period: + update_device_fw_info(self.hass, self.device, self.entry) elif update_type is RpcUpdateType.EVENT and (event := self.device.event): self._async_device_event_handler(event) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index b64b76534be..4d25812361c 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -375,13 +375,10 @@ def get_rpc_input_triggers(device: RpcDevice) -> list[tuple[str, str]]: @callback -def device_update_info( +def update_device_fw_info( hass: HomeAssistant, shellydevice: BlockDevice | RpcDevice, entry: ConfigEntry ) -> None: - """Update device registry info.""" - - LOGGER.debug("Updating device registry info for %s", entry.title) - + """Update the firmware version information in the device registry.""" assert entry.unique_id dev_reg = dr_async_get(hass) @@ -389,6 +386,11 @@ def device_update_info( identifiers={(DOMAIN, entry.entry_id)}, connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, ): + if device.sw_version == shellydevice.firmware_version: + return + + LOGGER.debug("Updating device registry info for %s", entry.title) + dev_reg.async_update_device(device.id, sw_version=shellydevice.firmware_version) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 3872f6f5a1a..8ce80b70032 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -23,8 +23,10 @@ from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import ( + CONNECTION_NETWORK_MAC, async_entries_for_config_entry, async_get as async_get_dev_reg, + format_mac, ) import homeassistant.helpers.issue_registry as ir @@ -632,3 +634,34 @@ async def test_rpc_polling_disconnected( await mock_polling_rpc_update(hass, freezer) assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + +async def test_rpc_update_entry_fw_ver( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC update entry firmware version.""" + entry = await init_integration(hass, 2, sleep_period=600) + dev_reg = async_get_dev_reg(hass) + + # Make device online + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + device = dev_reg.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + ) + + assert device.sw_version == "some fw string" + + monkeypatch.setattr(mock_rpc_device, "firmware_version", "99.0.0") + + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + device = dev_reg.async_get_device( + identifiers={(DOMAIN, entry.entry_id)}, + connections={(CONNECTION_NETWORK_MAC, format_mac(entry.unique_id))}, + ) + + assert device.sw_version == "99.0.0" From 17779c5f0c7b4d01a5746eb9f265d33773979e9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 4 Oct 2023 13:40:33 +0200 Subject: [PATCH 165/968] Add loader.async_suggest_report_issue and loader.async_get_issue_tracker (#101336) * Add loader.async_suggest_report_issue and loader.async_get_issue_tracker * Update tests * Add tests * Address review comments * Address review comments --- homeassistant/helpers/entity.py | 38 ++------ homeassistant/loader.py | 52 +++++++++++ tests/components/sensor/test_init.py | 2 +- tests/helpers/test_entity.py | 7 +- tests/test_loader.py | 129 +++++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 37 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 9b16b0c24fd..542841f7f7c 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from abc import ABC import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping -from contextlib import suppress from dataclasses import dataclass from datetime import timedelta from enum import Enum, auto @@ -50,11 +49,7 @@ from homeassistant.exceptions import ( InvalidStateError, NoEntitySpecifiedError, ) -from homeassistant.loader import ( - IntegrationNotLoaded, - async_get_loaded_integration, - bind_hass, -) +from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er @@ -1257,35 +1252,12 @@ class Entity(ABC): def _suggest_report_issue(self) -> str: """Suggest to report an issue.""" - report_issue = "" - - integration = None # The check for self.platform guards against integrations not using an # EntityComponent and can be removed in HA Core 2024.1 - if self.platform: - with suppress(IntegrationNotLoaded): - integration = async_get_loaded_integration( - self.hass, self.platform.platform_name - ) - - if "custom_components" in type(self).__module__: - if integration and integration.issue_tracker: - report_issue = f"create a bug report at {integration.issue_tracker}" - else: - report_issue = "report it to the custom integration author" - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - # The check for self.platform guards against integrations not using an - # EntityComponent and can be removed in HA Core 2024.1 - if self.platform: - report_issue += ( - f"+label%3A%22integration%3A+{self.platform.platform_name}%22" - ) - - return report_issue + platform_name = self.platform.platform_name if self.platform else None + return async_suggest_report_issue( + self.hass, integration_domain=platform_name, module=type(self).__module__ + ) @dataclass(slots=True) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index a3ddbf4cbca..6107150cebb 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1187,3 +1187,55 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: """Test if a component module is loaded.""" return module in hass.data[DATA_COMPONENTS] + + +@callback +def async_get_issue_tracker( + hass: HomeAssistant | None, + *, + integration_domain: str | None = None, + module: str | None = None, +) -> str | None: + """Return a URL for an integration's issue tracker.""" + issue_tracker = ( + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + if not integration_domain and not module: + # If we know nothing about the entity, suggest opening an issue on HA core + return issue_tracker + + if hass and integration_domain: + with suppress(IntegrationNotLoaded): + integration = async_get_loaded_integration(hass, integration_domain) + if not integration.is_built_in: + return integration.issue_tracker + + if module and "custom_components" in module: + return None + + if integration_domain: + issue_tracker += f"+label%3A%22integration%3A+{integration_domain}%22" + return issue_tracker + + +@callback +def async_suggest_report_issue( + hass: HomeAssistant | None, + *, + integration_domain: str | None = None, + module: str | None = None, +) -> str: + """Generate a blurb asking the user to file a bug report.""" + issue_tracker = async_get_issue_tracker( + hass, integration_domain=integration_domain, module=module + ) + + if not issue_tracker: + if not integration_domain: + return "report it to the custom integration author" + return ( + f"report it to the author of the '{integration_domain}' " + "custom integration" + ) + + return f"create a bug report at {issue_tracker}" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 01dfb9b3649..395f6d41a14 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -166,7 +166,7 @@ async def test_deprecated_last_reset( f"with state_class {state_class} has set last_reset. Setting last_reset for " "entities with state_class other than 'total' is not supported. Please update " "your configuration if state_class is manually configured, otherwise report it " - "to the custom integration author" + "to the author of the 'test' custom integration" ) in caplog.text state = hass.states.get("sensor.test") diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 61ee38a66a7..68a09310540 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -776,9 +776,10 @@ async def test_warn_slow_write_state_custom_component( mock_entity.async_write_ha_state() assert ( - "Updating state for comp_test.test_entity " - "(.CustomComponentEntity'>) " - "took 10.000 seconds. Please report it to the custom integration author" + "Updating state for comp_test.test_entity (.CustomComponentEntity'>)" + " took 10.000 seconds. Please report it to the author of the 'hue' custom " + "integration" ) in caplog.text diff --git a/tests/test_loader.py b/tests/test_loader.py index 4a03a7379b0..3c95111db3a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -744,3 +744,132 @@ async def test_loggers(hass: HomeAssistant) -> None: }, ) assert integration.loggers == ["name1", "name2"] + + +CORE_ISSUE_TRACKER = ( + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" +) +CORE_ISSUE_TRACKER_BUILT_IN = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_built_in%22" +) +CORE_ISSUE_TRACKER_CUSTOM = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_custom%22" +) +CORE_ISSUE_TRACKER_CUSTOM_NO_TRACKER = ( + CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+bla_custom_no_tracker%22" +) +CORE_ISSUE_TRACKER_HUE = CORE_ISSUE_TRACKER + "+label%3A%22integration%3A+hue%22" +CUSTOM_ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("domain", "module", "issue_tracker"), + [ + # If no information is available, open issue on core + (None, None, CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), + ("hue", None, CORE_ISSUE_TRACKER_HUE), + ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), + # 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 + ("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER), + ("bla_custom", None, CUSTOM_ISSUE_TRACKER), + # 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), + # Integration domain has priority over module + ("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None), + ], +) +async def test_async_get_issue_tracker( + hass, domain: str | None, module: str | None, issue_tracker: str | None +) -> None: + """Test async_get_issue_tracker.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + assert ( + loader.async_get_issue_tracker(hass, integration_domain=domain, module=module) + == issue_tracker + ) + + +@pytest.mark.parametrize( + ("domain", "module", "issue_tracker"), + [ + # If no information is available, open issue on core + (None, None, CORE_ISSUE_TRACKER), + ("hue", "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER_HUE), + ("hue", None, CORE_ISSUE_TRACKER_HUE), + ("bla_built_in", None, CORE_ISSUE_TRACKER_BUILT_IN), + # 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 - can't find it without hass + ("bla_custom", "custom_components.bla_custom.sensor", None), + # Assumed to be a core integration without hass and without module + ("bla_custom", None, CORE_ISSUE_TRACKER_CUSTOM), + ], +) +async def test_async_get_issue_tracker_no_hass( + hass, domain: str | None, module: str | None, issue_tracker: str +) -> None: + """Test async_get_issue_tracker.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + assert ( + loader.async_get_issue_tracker(None, integration_domain=domain, module=module) + == issue_tracker + ) + + +REPORT_CUSTOM = ( + "report it to the author of the 'bla_custom_no_tracker' custom integration" +) +REPORT_CUSTOM_UNKNOWN = "report it to the custom integration author" + + +@pytest.mark.parametrize( + ("domain", "module", "report_issue"), + [ + (None, None, f"create a bug report at {CORE_ISSUE_TRACKER}"), + ("bla_custom", None, f"create a bug report at {CUSTOM_ISSUE_TRACKER}"), + ("bla_custom_no_tracker", None, REPORT_CUSTOM), + (None, "custom_components.hue.sensor", REPORT_CUSTOM_UNKNOWN), + ], +) +async def test_async_suggest_report_issue( + hass, domain: str | None, module: str | None, report_issue: str +) -> None: + """Test async_suggest_report_issue.""" + mock_integration(hass, MockModule("bla_built_in")) + mock_integration( + hass, + MockModule( + "bla_custom", partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER} + ), + built_in=False, + ) + mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False) + assert ( + loader.async_suggest_report_issue( + hass, integration_domain=domain, module=module + ) + == report_issue + ) From ab6f617797dc79fe0777def983290ed711094b38 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 4 Oct 2023 13:45:59 +0000 Subject: [PATCH 166/968] Use `entity_registry_enabled_by_default` fixture in Kraken tests (#101379) Use entity_registry_enabled_by_default fixture --- tests/components/kraken/test_sensor.py | 107 ++----------------------- 1 file changed, 6 insertions(+), 101 deletions(-) diff --git a/tests/components/kraken/test_sensor.py b/tests/components/kraken/test_sensor.py index 8efac3017e0..5ef913ab74b 100644 --- a/tests/components/kraken/test_sensor.py +++ b/tests/components/kraken/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.kraken.const import ( ) from homeassistant.const import CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from .const import ( MISSING_PAIR_TICKER_INFORMATION_RESPONSE, @@ -25,7 +25,11 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed -async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> None: +async def test_sensor( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + entity_registry_enabled_by_default: None, +) -> None: """Test that sensor has a value.""" with patch( "pykrakenapi.KrakenAPI.get_tradable_asset_pairs", @@ -51,105 +55,6 @@ async def test_sensor(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No ) entry.add_to_hass(hass) - registry = er.async_get(hass) - - # Pre-create registry entries for disabled by default sensors - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_ask_volume", - suggested_object_id="xbt_usd_ask_volume", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_last_trade_closed", - suggested_object_id="xbt_usd_last_trade_closed", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_bid_volume", - suggested_object_id="xbt_usd_bid_volume", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_today", - suggested_object_id="xbt_usd_volume_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_last_24h", - suggested_object_id="xbt_usd_volume_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_weighted_average_today", - suggested_object_id="xbt_usd_volume_weighted_average_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_volume_weighted_average_last_24h", - suggested_object_id="xbt_usd_volume_weighted_average_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_number_of_trades_today", - suggested_object_id="xbt_usd_number_of_trades_today", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_number_of_trades_last_24h", - suggested_object_id="xbt_usd_number_of_trades_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_low_last_24h", - suggested_object_id="xbt_usd_low_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_high_last_24h", - suggested_object_id="xbt_usd_high_last_24h", - disabled_by=None, - ) - - registry.async_get_or_create( - "sensor", - DOMAIN, - "xbt_usd_opening_price_today", - suggested_object_id="xbt_usd_opening_price_today", - disabled_by=None, - ) - await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() From 5754e8721a6ddc5e8b8a6fbf804e63fdcf048be3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 4 Oct 2023 16:45:13 +0200 Subject: [PATCH 167/968] Fix Withings translations (#101397) --- homeassistant/components/withings/sensor.py | 5 ++--- homeassistant/components/withings/strings.json | 3 +++ tests/components/withings/snapshots/test_sensor.ambr | 8 ++++---- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 42f5ac18f2f..77a706dc55d 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -240,8 +240,7 @@ SENSORS = [ key=Measurement.SLEEP_HEART_RATE_MAX.value, measurement=Measurement.SLEEP_HEART_RATE_MAX, measure_type=GetSleepSummaryField.HR_MAX, - translation_key="fat_mass", - name="Maximum heart rate", + translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -251,7 +250,7 @@ SENSORS = [ key=Measurement.SLEEP_HEART_RATE_MIN.value, measurement=Measurement.SLEEP_HEART_RATE_MIN, measure_type=GetSleepSummaryField.HR_MIN, - translation_key="maximum_heart_rate", + translation_key="minimum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index ea925f535e3..df948a2b593 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -89,6 +89,9 @@ "maximum_heart_rate": { "name": "Maximum heart rate" }, + "minimum_heart_rate": { + "name": "Minimum heart rate" + }, "light_sleep": { "name": "Light sleep" }, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 6aa9e5b3784..9733880b03a 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -211,13 +211,13 @@ # name: test_all_entities.21 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Fat mass', + 'friendly_name': 'henk Maximum heart rate', 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), 'context': , - 'entity_id': 'sensor.henk_fat_mass_2', + 'entity_id': 'sensor.henk_maximum_heart_rate', 'last_changed': , 'last_updated': , 'state': '165.0', @@ -226,13 +226,13 @@ # name: test_all_entities.22 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum heart rate', + 'friendly_name': 'henk Minimum heart rate', 'icon': 'mdi:heart-pulse', 'state_class': , 'unit_of_measurement': 'bpm', }), 'context': , - 'entity_id': 'sensor.henk_maximum_heart_rate', + 'entity_id': 'sensor.henk_minimum_heart_rate', 'last_changed': , 'last_updated': , 'state': '166.0', From 2d766d43fc1c26faf09f7df908446f9fe79f314f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 4 Oct 2023 18:19:19 +0200 Subject: [PATCH 168/968] Prevent async_timeout import (#101378) --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 3fb5e28dedf..3c027e5ff6b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -628,6 +628,7 @@ voluptuous = "vol" fixture-parentheses = false [tool.ruff.flake8-tidy-imports.banned-api] +"async_timeout".msg = "use asyncio.timeout instead" "pytz".msg = "use zoneinfo instead" [tool.ruff.isort] From 1d7d7c3540bdad3e7a03a1fbcc8eec3dc18833f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=98yvind=20Matheson=20Wergeland?= Date: Wed, 4 Oct 2023 18:43:53 +0200 Subject: [PATCH 169/968] Fix translation keys in Hue (#101403) hue: fix key string syntax --- homeassistant/components/hue/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index a095c290b12..1af6d3b58b5 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -108,8 +108,8 @@ "switch": { "automation": { "state": { - "on": "[%key:common::state::enabled%", - "off": "[%key:common::state::disabled%" + "on": "[%key:common::state::enabled%]", + "off": "[%key:common::state::disabled%]" } } } From a3fe120457b7f9994fc49306bda3f45d8139e85e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 4 Oct 2023 19:36:34 +0200 Subject: [PATCH 170/968] Raise vol.Invalid for invalid mqtt device_tracker config (#101399) Raise vol.Invalid for invalid mqtt device_tracker --- .../components/mqtt/device_tracker.py | 2 +- tests/components/mqtt/test_discovery.py | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 2270f2b4031..2557a2afb5d 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -52,7 +52,7 @@ DEFAULT_SOURCE_TYPE = SourceType.GPS def valid_config(config: ConfigType) -> ConfigType: """Check if there is a state topic or json_attributes_topic.""" if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config: - raise vol.MultipleInvalid( + raise vol.Invalid( f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}" ) return config diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index c528687623b..4d0b8457049 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -122,6 +122,28 @@ async def test_invalid_json( assert not mock_dispatcher_send.called +@pytest.mark.no_fail_on_log_exception +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_discovery_schema_error( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test unexpected error JSON config.""" + with patch( + "homeassistant.components.mqtt.binary_sensor.DISCOVERY_SCHEMA", + side_effect=AttributeError("Attribute abc not found"), + ): + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{"name": "Beer", "state_topic": "ok"}', + ) + await hass.async_block_till_done() + assert "AttributeError: Attribute abc not found" in caplog.text + + async def test_only_valid_components( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, From c495d607d8b2695a36f5093817a7f300d1c782e6 Mon Sep 17 00:00:00 2001 From: Toastme Date: Wed, 4 Oct 2023 15:09:24 -0400 Subject: [PATCH 171/968] Update tplink manifest.json with 2 new MACs for KP200 (#101359) * Update manifest.json with 2 new MACs for KP200 those MAC are missing from the list so there are not detected like the other K200 i have (like 68ff7b) * run hassfest --------- Co-authored-by: J. Nick Koston --- homeassistant/components/tplink/manifest.json | 8 ++++++++ homeassistant/generated/dhcp.py | 10 ++++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index b2fcc5c0161..d13adb8ec47 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -155,6 +155,14 @@ { "hostname": "k[lps]*", "macaddress": "788CB5*" + }, + { + "hostname": "k[lps]*", + "macaddress": "3460F9*" + }, + { + "hostname": "k[lps]*", + "macaddress": "1C61B4*" } ], "documentation": "https://www.home-assistant.io/integrations/tplink", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 91a02ac3e06..bc73c1b9804 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -784,6 +784,16 @@ DHCP: list[dict[str, str | bool]] = [ "hostname": "k[lps]*", "macaddress": "788CB5*", }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "3460F9*", + }, + { + "domain": "tplink", + "hostname": "k[lps]*", + "macaddress": "1C61B4*", + }, { "domain": "tuya", "macaddress": "105A17*", From 7e39acda3757fb2ac03ee32d99e3f4865a85dc7a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 4 Oct 2023 21:43:00 +0200 Subject: [PATCH 172/968] Minor improvement of frame helper (#101387) * Minor improvement of frame helper * Add new custom integration for testing * Make IntegrationFrame kw_only --- homeassistant/helpers/frame.py | 25 +++++++++++---- tests/helpers/test_frame.py | 32 +++++++++++++++++-- .../test_integration_frame/__init__.py | 8 +++++ .../test_integration_frame/manifest.json | 9 ++++++ 4 files changed, 64 insertions(+), 10 deletions(-) create mode 100644 tests/testing_config/custom_components/test_integration_frame/__init__.py create mode 100644 tests/testing_config/custom_components/test_integration_frame/manifest.json diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 084a781bf62..19767c39284 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -6,6 +6,7 @@ from collections.abc import Callable from dataclasses import dataclass import functools import logging +import sys from traceback import FrameSummary, extract_stack from typing import Any, TypeVar, cast @@ -19,14 +20,15 @@ _REPORTED_INTEGRATIONS: set[str] = set() _CallableT = TypeVar("_CallableT", bound=Callable) -@dataclass +@dataclass(kw_only=True) class IntegrationFrame: """Integration frame container.""" custom_integration: bool - filename: str frame: FrameSummary integration: str + module: str | None + relative_filename: str def get_integration_frame(exclude_integrations: set | None = None) -> IntegrationFrame: @@ -55,11 +57,20 @@ def get_integration_frame(exclude_integrations: set | None = None) -> Integratio if found_frame is None: raise MissingIntegrationFrame + found_module: str | None = None + for module, module_obj in dict(sys.modules).items(): + if not hasattr(module_obj, "__file__"): + continue + if module_obj.__file__ == found_frame.filename: + found_module = module + break + return IntegrationFrame( - path == "custom_components/", - found_frame.filename[index:], - found_frame, - integration, + custom_integration=path == "custom_components/", + frame=found_frame, + integration=integration, + module=found_module, + relative_filename=found_frame.filename[index:], ) @@ -121,7 +132,7 @@ def _report_integration( what, extra, integration_frame.integration, - integration_frame.filename, + integration_frame.relative_filename, found_frame.lineno, (found_frame.line or "?").strip(), ) diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 53d799a0400..6756f14bf08 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -1,10 +1,11 @@ """Test the frame helper.""" from collections.abc import Generator -from unittest.mock import Mock, patch +from unittest.mock import ANY, Mock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers import frame @@ -41,7 +42,28 @@ async def test_extract_frame_integration( """Test extracting the current frame from integration context.""" integration_frame = frame.get_integration_frame() assert integration_frame == frame.IntegrationFrame( - False, "homeassistant/components/hue/light.py", mock_integration_frame, "hue" + custom_integration=False, + frame=mock_integration_frame, + integration="hue", + module=None, + relative_filename="homeassistant/components/hue/light.py", + ) + + +async def test_extract_frame_resolve_module( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test extracting the current frame from integration context.""" + from custom_components.test_integration_frame import call_get_integration_frame + + integration_frame = call_get_integration_frame() + + assert integration_frame == frame.IntegrationFrame( + custom_integration=True, + frame=ANY, + integration="test_integration_frame", + module="custom_components.test_integration_frame", + relative_filename="custom_components/test_integration_frame/__init__.py", ) @@ -80,7 +102,11 @@ async def test_extract_frame_integration_with_excluded_integration( ) assert integration_frame == frame.IntegrationFrame( - False, "homeassistant/components/mdns/light.py", correct_frame, "mdns" + custom_integration=False, + frame=correct_frame, + integration="mdns", + module=None, + relative_filename="homeassistant/components/mdns/light.py", ) diff --git a/tests/testing_config/custom_components/test_integration_frame/__init__.py b/tests/testing_config/custom_components/test_integration_frame/__init__.py new file mode 100644 index 00000000000..d342509d52e --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_frame/__init__.py @@ -0,0 +1,8 @@ +"""An integration which calls helpers.frame.get_integration_frame.""" + +from homeassistant.helpers import frame + + +def call_get_integration_frame() -> frame.IntegrationFrame: + """Call get_integration_frame.""" + return frame.get_integration_frame() diff --git a/tests/testing_config/custom_components/test_integration_frame/manifest.json b/tests/testing_config/custom_components/test_integration_frame/manifest.json new file mode 100644 index 00000000000..3c3eceec28d --- /dev/null +++ b/tests/testing_config/custom_components/test_integration_frame/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "test_integration_frame", + "name": "Test Integration Frame", + "documentation": "http://example.com", + "requirements": [], + "dependencies": [], + "codeowners": [], + "version": "1.2.3" +} From db71e8033c60a2e14feaaa814ce6b307f67bced9 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 4 Oct 2023 16:32:02 -0500 Subject: [PATCH 173/968] Bump plexapi to 4.15.4 (#101381) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 6cf94793173..33641cdf44f 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["plexapi", "plexwebsocket"], "requirements": [ - "PlexAPI==4.15.3", + "PlexAPI==4.15.4", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index f0eb01784fc..5319932bb52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -46,7 +46,7 @@ Mastodon.py==1.5.1 Pillow==10.0.1 # homeassistant.components.plex -PlexAPI==4.15.3 +PlexAPI==4.15.4 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbc2e766665..b559762421b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -42,7 +42,7 @@ HATasmota==0.7.3 Pillow==10.0.1 # homeassistant.components.plex -PlexAPI==4.15.3 +PlexAPI==4.15.4 # homeassistant.components.progettihwsw ProgettiHWSW==0.1.3 From c951c03447c3bf0961e27b5a594ffa43320029f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Oct 2023 21:01:10 -0400 Subject: [PATCH 174/968] Bump dbus-fast to 2.11.1 (#101406) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.11.0...v2.11.1 --- 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 2e2d6fa45ed..04815dc8972 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.12.0", - "dbus-fast==2.11.0" + "dbus-fast==2.11.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 51d03a40971..95ac592dadb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 -dbus-fast==2.11.0 +dbus-fast==2.11.1 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.71.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5319932bb52..d41e72429d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -645,7 +645,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.11.0 +dbus-fast==2.11.1 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b559762421b..b7ebb1610e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -528,7 +528,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.11.0 +dbus-fast==2.11.1 # homeassistant.components.debugpy debugpy==1.8.0 From 383c63000ecbb81525a3537bf4fe3176341dbfb1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 4 Oct 2023 22:55:18 -0400 Subject: [PATCH 175/968] Handle invalid scale for zwave_js multilevel/meter sensors (#101173) * Handle invalid scale for zwave_js multilevel/meter sensors * Remove logging statement --- .../zwave_js/discovery_data_template.py | 40 ++++++---- tests/components/zwave_js/test_sensor.py | 80 +++++++++++++++++-- 2 files changed, 95 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 7a274df41f2..b633e2a614f 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Iterable, Mapping from dataclasses import dataclass, field import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.energy_production import ( @@ -87,6 +87,7 @@ from zwave_js_server.const.command_class.multilevel_sensor import ( MultilevelSensorScaleType, MultilevelSensorType, ) +from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue as ZwaveConfigurationValue, @@ -355,24 +356,22 @@ class NumericSensorDataTemplateData: unit_of_measurement: str | None = None +T = TypeVar( + "T", + MultilevelSensorType, + MultilevelSensorScaleType, + MeterScaleType, + EnergyProductionParameter, + EnergyProductionScaleType, +) + + class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave Sensor entities.""" @staticmethod def find_key_from_matching_set( - enum_value: MultilevelSensorType - | MultilevelSensorScaleType - | MeterScaleType - | EnergyProductionParameter - | EnergyProductionScaleType, - set_map: Mapping[ - str, - list[MultilevelSensorType] - | list[MultilevelSensorScaleType] - | list[MeterScaleType] - | list[EnergyProductionScaleType] - | list[EnergyProductionParameter], - ], + enum_value: T, set_map: Mapping[str, list[T]] ) -> str | None: """Find a key in a set map that matches a given enum value.""" for key, value_set in set_map.items(): @@ -393,7 +392,11 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): return NumericSensorDataTemplateData(ENTITY_DESC_KEY_BATTERY, PERCENTAGE) if value.command_class == CommandClass.METER: - meter_scale_type = get_meter_scale_type(value) + try: + meter_scale_type = get_meter_scale_type(value) + except UnknownValueData: + return NumericSensorDataTemplateData() + unit = self.find_key_from_matching_set(meter_scale_type, METER_UNIT_MAP) # We do this because even though these are energy scales, they don't meet # the unit requirements for the energy device class. @@ -418,8 +421,11 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): ) if value.command_class == CommandClass.SENSOR_MULTILEVEL: - sensor_type = get_multilevel_sensor_type(value) - multilevel_sensor_scale_type = get_multilevel_sensor_scale_type(value) + try: + sensor_type = get_multilevel_sensor_type(value) + multilevel_sensor_scale_type = get_multilevel_sensor_scale_type(value) + except UnknownValueData: + return NumericSensorDataTemplateData() unit = self.find_key_from_matching_set( multilevel_sensor_scale_type, MULTILEVEL_SENSOR_UNIT_MAP ) diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index d452f28b3bf..f00413b0d80 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -130,6 +130,40 @@ async def test_numeric_sensor( assert state.state == "0" +async def test_invalid_multilevel_sensor_scale( + hass: HomeAssistant, client, multisensor_6_state, integration +) -> None: + """Test a multilevel sensor with an invalid scale.""" + node_state = copy.deepcopy(multisensor_6_state) + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 49 and value["property"] == "Air temperature" + ) + value["metadata"]["ccSpecific"]["scale"] = -1 + value["metadata"]["unit"] = None + + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(AIR_TEMPERATURE_SENSOR) + + assert state + assert state.state == "9.0" + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + + async def test_energy_sensors( hass: HomeAssistant, hank_binary_switch, integration ) -> None: @@ -424,10 +458,7 @@ async def test_node_status_sensor_not_ready( async def test_reset_meter( - hass: HomeAssistant, - client, - aeon_smart_switch_6, - integration, + hass: HomeAssistant, client, aeon_smart_switch_6, integration ) -> None: """Test reset_meter service.""" client.async_send_command.return_value = {} @@ -487,10 +518,7 @@ async def test_reset_meter( async def test_meter_attributes( - hass: HomeAssistant, - client, - aeon_smart_switch_6, - integration, + hass: HomeAssistant, client, aeon_smart_switch_6, integration ) -> None: """Test meter entity attributes.""" state = hass.states.get(METER_ENERGY_SENSOR) @@ -501,6 +529,42 @@ async def test_meter_attributes( assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING +async def test_invalid_meter_scale( + hass: HomeAssistant, client, aeon_smart_switch_6_state, integration +) -> None: + """Test a meter sensor with an invalid scale.""" + node_state = copy.deepcopy(aeon_smart_switch_6_state) + value = next( + value + for value in node_state["values"] + if value["commandClass"] == 50 + and value["property"] == "value" + and value["propertyKey"] == 65537 + ) + value["metadata"]["ccSpecific"]["scale"] = -1 + value["metadata"]["unit"] = None + + event = Event( + "node added", + { + "source": "controller", + "event": "node added", + "node": node_state, + "result": "", + }, + ) + client.driver.controller.receive_event(event) + await hass.async_block_till_done() + + state = hass.states.get(METER_ENERGY_SENSOR) + assert state + assert state.attributes[ATTR_METER_TYPE] == MeterType.ELECTRIC.value + assert state.attributes[ATTR_METER_TYPE_NAME] == MeterType.ELECTRIC.name + assert ATTR_DEVICE_CLASS not in state.attributes + assert ATTR_STATE_CLASS not in state.attributes + assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + + async def test_special_meters( hass: HomeAssistant, aeon_smart_switch_6_state, client, integration ) -> None: From ca2e335ab9e4467a161588d1ff9ebbc7301725ab Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Thu, 5 Oct 2023 01:11:17 -0400 Subject: [PATCH 176/968] Bump env_canada to v0.5.37 (#101435) --- homeassistant/components/environment_canada/manifest.json | 2 +- pyproject.toml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 0575ac132d4..4946c1900ea 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.5.36"] + "requirements": ["env-canada==0.5.37"] } diff --git a/pyproject.toml b/pyproject.toml index 3c027e5ff6b..b0b82184785 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -437,7 +437,7 @@ filterwarnings = [ # -- design choice 3rd party # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/michaeldavie/env_canada/blob/v0.5.36/env_canada/ec_cache.py + # https://github.com/michaeldavie/env_canada/blob/v0.5.37/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", diff --git a/requirements_all.txt b/requirements_all.txt index d41e72429d3..f50ec73010d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -749,7 +749,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.5.36 +env-canada==0.5.37 # homeassistant.components.season ephem==4.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b7ebb1610e8..cdade782f73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -605,7 +605,7 @@ energyzero==0.5.0 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.5.36 +env-canada==0.5.37 # homeassistant.components.season ephem==4.1.2 From 0c8e1a691d2c51de7ad64dffcb28249f45e2b348 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Thu, 5 Oct 2023 13:14:14 +0800 Subject: [PATCH 177/968] Bump pyyardian to 1.1.1 (#101363) * Update Yardian Dependency * test requirements --- homeassistant/components/yardian/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yardian/manifest.json b/homeassistant/components/yardian/manifest.json index a20315278b4..ba6396e1f75 100644 --- a/homeassistant/components/yardian/manifest.json +++ b/homeassistant/components/yardian/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yardian", "iot_class": "local_polling", - "requirements": ["pyyardian==1.1.0"] + "requirements": ["pyyardian==1.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f50ec73010d..1a5f9880004 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2270,7 +2270,7 @@ pyws66i==1.1 pyxeoma==1.4.1 # homeassistant.components.yardian -pyyardian==1.1.0 +pyyardian==1.1.1 # homeassistant.components.qrcode pyzbar==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdade782f73..cb33cdcc98a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1693,7 +1693,7 @@ pywizlight==0.5.14 pyws66i==1.1 # homeassistant.components.yardian -pyyardian==1.1.0 +pyyardian==1.1.1 # homeassistant.components.zerproc pyzerproc==0.4.8 From ef066626c834d3acad16f06fbf126ae29e90e3a1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 5 Oct 2023 08:32:43 +0200 Subject: [PATCH 178/968] Add translation for Tamper binary sensor (#101416) --- homeassistant/components/binary_sensor/strings.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index b86c013f104..573b154e2a4 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -286,6 +286,13 @@ "on": "[%key:component::binary_sensor::entity_component::gas::state::on%]" } }, + "tamper": { + "name": "Tamper", + "state": { + "off": "[%key:component::binary_sensor::entity_component::gas::state::off%]", + "on": "Tampering detected" + } + }, "update": { "name": "Update", "state": { From 5975974a370c92742d858d075e7f955d6a6dce43 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Thu, 5 Oct 2023 08:36:58 +0200 Subject: [PATCH 179/968] Bumb pypoint to 2.3.2 (#101436) version bumb point --- homeassistant/components/point/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json index 770f1e50edf..3c2a82dfb98 100644 --- a/homeassistant/components/point/manifest.json +++ b/homeassistant/components/point/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["pypoint"], "quality_scale": "gold", - "requirements": ["pypoint==2.3.0"] + "requirements": ["pypoint==2.3.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1a5f9880004..59bd9f9dbc4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1946,7 +1946,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.0 +pypoint==2.3.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb33cdcc98a..f9d922ffdeb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1468,7 +1468,7 @@ pypjlink2==1.2.1 pyplaato==0.0.18 # homeassistant.components.point -pypoint==2.3.0 +pypoint==2.3.2 # homeassistant.components.profiler pyprof2calltree==1.4.5 From 2464232f24b0c7cb71d61b9967d9cb9431ce56c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 13:23:59 +0200 Subject: [PATCH 180/968] Fix call to API in airnow option flow tests (#101457) --- tests/components/airnow/test_config_flow.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py index f62fc9aee22..f4a0fdeec1e 100644 --- a/tests/components/airnow/test_config_flow.py +++ b/tests/components/airnow/test_config_flow.py @@ -1,5 +1,5 @@ """Test the AirNow config flow.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from pyairnow.errors import AirNowError, EmptyResponseError, InvalidKeyError import pytest @@ -142,12 +142,18 @@ async def test_options_flow(hass: HomeAssistant, setup_airnow) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "init" - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_RADIUS: 25}, - ) + with patch( + "homeassistant.components.airnow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_RADIUS: 25}, + ) + await hass.async_block_till_done() assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == { CONF_RADIUS: 25, } + assert len(mock_setup_entry.mock_calls) == 1 From 7f912cb6698f0e7bbc6f18482ac9cddaf85e32f8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 13:24:28 +0200 Subject: [PATCH 181/968] Fix airnow test fixture (#101458) --- tests/components/airnow/conftest.py | 7 ++----- tests/components/airnow/test_diagnostics.py | 1 + 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/components/airnow/conftest.py b/tests/components/airnow/conftest.py index 4e9d1698e8c..0356c6f3395 100644 --- a/tests/components/airnow/conftest.py +++ b/tests/components/airnow/conftest.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.airnow import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_fixture @@ -60,8 +59,6 @@ def mock_api_get_fixture(data): async def setup_airnow_fixture(hass, config, mock_api_get): """Define a fixture to set up AirNow.""" with patch("pyairnow.WebServiceAPI._get", mock_api_get), patch( - "homeassistant.components.airnow.config_flow.WebServiceAPI._get", mock_api_get - ), patch("homeassistant.components.airnow.PLATFORMS", []): - assert await async_setup_component(hass, DOMAIN, config) - await hass.async_block_till_done() + "homeassistant.components.airnow.PLATFORMS", [] + ): yield diff --git a/tests/components/airnow/test_diagnostics.py b/tests/components/airnow/test_diagnostics.py index ecf6acc1c80..50ff3ed2b32 100644 --- a/tests/components/airnow/test_diagnostics.py +++ b/tests/components/airnow/test_diagnostics.py @@ -15,6 +15,7 @@ async def test_entry_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" + assert await hass.config_entries.async_setup(config_entry.entry_id) assert ( await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == snapshot From 0e41542ff3b67696604686342829fde1dc7a87c6 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 5 Oct 2023 19:25:48 +0200 Subject: [PATCH 182/968] Update frontend to 20231005.0 (#101480) --- 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 40339e955f9..0d1c1659471 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==20231002.0"] + "requirements": ["home-assistant-frontend==20231005.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 95ac592dadb..9fa36c633bd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ ha-av==10.1.1 hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20231002.0 +home-assistant-frontend==20231005.0 home-assistant-intents==2023.10.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 59bd9f9dbc4..bcc221921ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -997,7 +997,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231002.0 +home-assistant-frontend==20231005.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9d922ffdeb..c2209a0c13f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -786,7 +786,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231002.0 +home-assistant-frontend==20231005.0 # homeassistant.components.conversation home-assistant-intents==2023.10.2 From cb0a05142d1936d0fe1318ac074fbb9c59429e9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 5 Oct 2023 12:50:35 -0500 Subject: [PATCH 183/968] Bump zeroconf to 0.115.2 (#101482) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 53475588cfe..4c76a0c46ef 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.115.1"] + "requirements": ["zeroconf==0.115.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9fa36c633bd..005a6735e03 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.1 +zeroconf==0.115.2 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index bcc221921ca..53ba0c208ff 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,7 +2784,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.1 +zeroconf==0.115.2 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2209a0c13f..69770e3338d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2078,7 +2078,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.1 +zeroconf==0.115.2 # homeassistant.components.zeversolar zeversolar==0.3.1 From dce5099d92bce99bc1e072ff3ab4d4d539d557cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 19:52:26 +0200 Subject: [PATCH 184/968] Use loader.async_suggest_report_issue in frame helper (#101461) --- homeassistant/helpers/frame.py | 25 +++++++++++++++---------- tests/helpers/test_aiohttp_client.py | 18 ++++++++++-------- tests/helpers/test_frame.py | 9 +++++---- tests/helpers/test_httpx_client.py | 19 +++++++++++-------- 4 files changed, 41 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 19767c39284..920c7150f6d 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass import functools import logging @@ -10,7 +11,9 @@ import sys from traceback import FrameSummary, extract_stack from typing import Any, TypeVar, cast +from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue _LOGGER = logging.getLogger(__name__) @@ -118,23 +121,25 @@ def _report_integration( return _REPORTED_INTEGRATIONS.add(key) - if integration_frame.custom_integration: - extra = " to the custom integration author" - else: - extra = "" + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) _LOGGER.log( level, - ( - "Detected integration that %s. " - "Please report issue%s for %s using this method at %s, line %s: %s" - ), - what, - extra, + "Detected that %sintegration '%s' %s at %s, line %s: %s, please %s", + "custom " if integration_frame.custom_integration else "", integration_frame.integration, + what, integration_frame.relative_filename, found_frame.lineno, (found_frame.line or "?").strip(), + report_issue, ) diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index fe7ffca9a47..daeb324b19f 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -21,7 +21,7 @@ from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, HomeAssistant import homeassistant.helpers.aiohttp_client as client from homeassistant.util.color import RGBColor -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, MockModule, mock_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -155,9 +155,10 @@ async def test_warning_close_session_integration( session = client.async_get_clientsession(hass) await session.close() assert ( - "Detected integration that closes the Home Assistant aiohttp session. " - "Please report issue for hue using this method at " - "homeassistant/components/hue/light.py, line 23: await session.close()" + "Detected that integration 'hue' closes the Home Assistant aiohttp session at " + "homeassistant/components/hue/light.py, line 23: await session.close(), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -166,6 +167,7 @@ async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test log warning message when closing the session from custom context.""" + mock_integration(hass, MockModule("hue"), built_in=False) with patch( "homeassistant.helpers.frame.extract_stack", return_value=[ @@ -189,10 +191,10 @@ async def test_warning_close_session_custom( session = client.async_get_clientsession(hass) await session.close() assert ( - "Detected integration that closes the Home Assistant aiohttp session. Please" - " report issue to the custom integration author for hue using this method at" - " custom_components/hue/light.py, line 23: await session.close()" in caplog.text - ) + "Detected that custom integration 'hue' closes the Home Assistant aiohttp " + "session at custom_components/hue/light.py, line 23: await session.close(), " + "please report it to the author of the 'hue' custom integration" + ) in caplog.text async def test_async_aiohttp_proxy_stream( diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py index 6756f14bf08..f1547f36e39 100644 --- a/tests/helpers/test_frame.py +++ b/tests/helpers/test_frame.py @@ -132,7 +132,7 @@ async def test_extract_frame_no_integration(caplog: pytest.LogCaptureFixture) -> @patch.object(frame, "_REPORTED_INTEGRATIONS", set()) async def test_prevent_flooding( - caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_integration_frame: Mock ) -> None: """Test to ensure a report is only written once to the log.""" @@ -142,9 +142,10 @@ async def test_prevent_flooding( filename = "homeassistant/components/hue/light.py" expected_message = ( - f"Detected integration that {what}. Please report issue for {integration} using" - f" this method at {filename}, line " - f"{mock_integration_frame.lineno}: {mock_integration_frame.line}" + f"Detected that integration '{integration}' {what} at {filename}, line " + f"{mock_integration_frame.lineno}: {mock_integration_frame.line}, " + f"please create a bug report at https://github.com/home-assistant/core/issues?" + f"q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+{integration}%22" ) frame.report(what, error_if_core=False) diff --git a/tests/helpers/test_httpx_client.py b/tests/helpers/test_httpx_client.py index f9473ffaf87..693c45cc73a 100644 --- a/tests/helpers/test_httpx_client.py +++ b/tests/helpers/test_httpx_client.py @@ -7,6 +7,8 @@ import pytest from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE, HomeAssistant import homeassistant.helpers.httpx_client as client +from tests.common import MockModule, mock_integration + async def test_get_async_client_with_ssl(hass: HomeAssistant) -> None: """Test init async client with ssl.""" @@ -125,9 +127,10 @@ async def test_warning_close_session_integration( await httpx_session.aclose() assert ( - "Detected integration that closes the Home Assistant httpx client. " - "Please report issue for hue using this method at " - "homeassistant/components/hue/light.py, line 23: await session.aclose()" + "Detected that integration 'hue' closes the Home Assistant httpx client at " + "homeassistant/components/hue/light.py, line 23: await session.aclose(), " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text @@ -136,6 +139,7 @@ async def test_warning_close_session_custom( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test log warning message when closing the session from custom context.""" + mock_integration(hass, MockModule("hue"), built_in=False) with patch( "homeassistant.helpers.frame.extract_stack", return_value=[ @@ -159,8 +163,7 @@ async def test_warning_close_session_custom( httpx_session = client.get_async_client(hass) await httpx_session.aclose() assert ( - "Detected integration that closes the Home Assistant httpx client. Please" - " report issue to the custom integration author for hue using this method at" - " custom_components/hue/light.py, line 23: await session.aclose()" - in caplog.text - ) + "Detected that custom integration 'hue' closes the Home Assistant httpx client " + "at custom_components/hue/light.py, line 23: await session.aclose(), " + "please report it to the author of the 'hue' custom integration" + ) in caplog.text From 3d9073693c69f81cf0eb3f7f782e2e573da3ca2e Mon Sep 17 00:00:00 2001 From: Betacart Date: Thu, 5 Oct 2023 19:55:50 +0200 Subject: [PATCH 185/968] Fix typo -> "Kay" to "Key" in Minio (#101472) --- homeassistant/components/minio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/minio/strings.json b/homeassistant/components/minio/strings.json index 75b8375adb1..68a4786bc63 100644 --- a/homeassistant/components/minio/strings.json +++ b/homeassistant/components/minio/strings.json @@ -9,7 +9,7 @@ "description": "Bucket to use." }, "key": { - "name": "Kay", + "name": "Key", "description": "Object key of the file." }, "file_path": { From 0c40c8465e368f1f070ce3e961b2ee709ae71883 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 19:56:47 +0200 Subject: [PATCH 186/968] Correct checks for deprecated forecast in weather (#101392) Co-authored-by: Robert Resch --- homeassistant/components/weather/__init__.py | 110 ++++++---- homeassistant/components/weather/strings.json | 10 +- tests/components/weather/test_init.py | 194 ++++++++++++++++-- .../custom_components/test/weather.py | 36 ---- 4 files changed, 253 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 4ec9ea91f89..648201f16d2 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -8,7 +8,6 @@ from contextlib import suppress from dataclasses import dataclass from datetime import timedelta from functools import partial -import inspect import logging from typing import ( Any, @@ -56,6 +55,7 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, TimestampDataUpdateCoordinator, ) +from homeassistant.loader import async_get_issue_tracker, async_suggest_report_issue from homeassistant.util.dt import utcnow from homeassistant.util.json import JsonValueType from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -296,7 +296,8 @@ class WeatherEntity(Entity, PostInit): Literal["daily", "hourly", "twice_daily"], list[Callable[[list[JsonValueType] | None], None]], ] - __weather_legacy_forecast: bool = False + __weather_reported_legacy_forecast = False + __weather_legacy_forecast = False _weather_option_temperature_unit: str | None = None _weather_option_pressure_unit: str | None = None @@ -311,15 +312,12 @@ class WeatherEntity(Entity, PostInit): def __init_subclass__(cls, **kwargs: Any) -> None: """Post initialisation processing.""" super().__init_subclass__(**kwargs) - if any( - method in cls.__dict__ for method in ("_attr_forecast", "forecast") - ) and not any( - method in cls.__dict__ - for method in ( - "async_forecast_daily", - "async_forecast_hourly", - "async_forecast_twice_daily", - ) + if ( + "forecast" in cls.__dict__ + and cls.async_forecast_daily is WeatherEntity.async_forecast_daily + and cls.async_forecast_hourly is WeatherEntity.async_forecast_hourly + and cls.async_forecast_twice_daily + is WeatherEntity.async_forecast_twice_daily ): cls.__weather_legacy_forecast = True @@ -332,38 +330,55 @@ class WeatherEntity(Entity, PostInit): ) -> None: """Start adding an entity to a platform.""" super().add_to_platform_start(hass, platform, parallel_updates) - _reported_forecast = False - if self.__weather_legacy_forecast and not _reported_forecast: - module = inspect.getmodule(self) - if module and module.__file__ and "custom_components" in module.__file__: - # Do not report on core integrations as they are already fixed or PR is open. - report_issue = "report it to the custom integration author." - _LOGGER.warning( - ( - "%s::%s is using a forecast attribute on an instance of " - "WeatherEntity, this is deprecated and will be unsupported " - "from Home Assistant 2024.3. Please %s" - ), - self.__module__, - self.entity_id, - report_issue, - ) - ir.async_create_issue( - self.hass, - DOMAIN, - f"deprecated_weather_forecast_{self.platform.platform_name}", - breaks_in_ha_version="2024.3.0", - is_fixable=False, - is_persistent=False, - issue_domain=self.platform.platform_name, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_weather_forecast", - translation_placeholders={ - "platform": self.platform.platform_name, - "report_issue": report_issue, - }, - ) - _reported_forecast = True + if self.__weather_legacy_forecast: + self._report_legacy_forecast(hass) + + def _report_legacy_forecast(self, hass: HomeAssistant) -> None: + """Log warning and create an issue if the entity imlpements legacy forecast.""" + if "custom_components" not in type(self).__module__: + # Do not report core integrations as they are already fixed or PR is open. + return + + report_issue = async_suggest_report_issue( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + _LOGGER.warning( + ( + "%s::%s implements the `forecast` property or sets " + "`self._attr_forecast` in a subclass of WeatherEntity, this is " + "deprecated and will be unsupported from Home Assistant 2024.3." + " Please %s" + ), + self.platform.platform_name, + self.__class__.__name__, + report_issue, + ) + + translation_placeholders = {"platform": self.platform.platform_name} + translation_key = "deprecated_weather_forecast_no_url" + issue_tracker = async_get_issue_tracker( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + if issue_tracker: + translation_placeholders["issue_tracker"] = issue_tracker + translation_key = "deprecated_weather_forecast_url" + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_weather_forecast_{self.platform.platform_name}", + breaks_in_ha_version="2024.3.0", + is_fixable=False, + is_persistent=False, + issue_domain=self.platform.platform_name, + severity=ir.IssueSeverity.WARNING, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + self.__weather_reported_legacy_forecast = True async def async_internal_added_to_hass(self) -> None: """Call when the weather entity is added to hass.""" @@ -554,6 +569,15 @@ class WeatherEntity(Entity, PostInit): Should not be overridden by integrations. Kept for backwards compatibility. """ + if ( + self._attr_forecast is not None + and type(self).async_forecast_daily is WeatherEntity.async_forecast_daily + and type(self).async_forecast_hourly is WeatherEntity.async_forecast_hourly + and type(self).async_forecast_twice_daily + is WeatherEntity.async_forecast_twice_daily + and not self.__weather_reported_legacy_forecast + ): + self._report_legacy_forecast(self.hass) return self._attr_forecast async def async_forecast_daily(self) -> list[Forecast] | None: diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index 26388c217eb..f76e93c66c3 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -100,9 +100,13 @@ } }, "issues": { - "deprecated_weather_forecast": { - "title": "The {platform} integration is using deprecated forecast", - "description": "The integration `{platform}` is using the deprecated forecast attribute.\n\nPlease {report_issue}." + "deprecated_weather_forecast_url": { + "title": "The {platform} custom integration is using deprecated weather forecast", + "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_weather_forecast_no_url": { + "title": "[%key:component::weather::issues::deprecated_weather_forecast_url::title%]", + "description": "The custom integration `{platform}` implements the `forecast` property or sets `self._attr_forecast` in a subclass of WeatherEntity.\n\nPlease report it to the author of the {platform} integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." } } } diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index db3a18db914..231f08c7cc1 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,4 +1,5 @@ """The test for weather entity.""" +from collections.abc import Generator from datetime import datetime from typing import Any @@ -8,13 +9,23 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_FORECAST, ATTR_FORECAST_APPARENT_TEMP, + ATTR_FORECAST_CLOUD_COVERAGE, ATTR_FORECAST_DEW_POINT, ATTR_FORECAST_HUMIDITY, + ATTR_FORECAST_NATIVE_APPARENT_TEMP, + ATTR_FORECAST_NATIVE_DEW_POINT, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_UV_INDEX, + ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_GUST_SPEED, ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_APPARENT_TEMPERATURE, @@ -44,6 +55,7 @@ from homeassistant.components.weather.const import ( ATTR_WEATHER_DEW_POINT, ATTR_WEATHER_HUMIDITY, ) +from homeassistant.config_entries import ConfigEntry, ConfigFlow from homeassistant.const import ( PRECISION_HALVES, PRECISION_TENTHS, @@ -56,6 +68,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -69,6 +82,14 @@ from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM from . import create_entity +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) from tests.testing_config.custom_components.test import weather as WeatherPlatform from tests.testing_config.custom_components.test_weather import ( weather as NewWeatherPlatform, @@ -950,7 +971,150 @@ async def test_get_forecast_unsupported( ) -async def test_issue_forecast_deprecated( +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, "test.config_flow") + + with mock_config_flow("test", MockFlow): + yield + + +ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("manifest_extra", "translation_key", "translation_placeholders_extra", "report"), + [ + ( + {}, + "deprecated_weather_forecast_no_url", + {}, + "report it to the author of the 'test' custom integration", + ), + ( + {"issue_tracker": ISSUE_TRACKER}, + "deprecated_weather_forecast_url", + {"issue_tracker": ISSUE_TRACKER}, + "create a bug report at https://blablabla.com", + ), + ], +) +async def test_issue_forecast_property_deprecated( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + config_flow_fixture: None, + manifest_extra: dict[str, str], + translation_key: str, + translation_placeholders_extra: dict[str, str], + report: str, +) -> None: + """Test the issue is raised on deprecated forecast attributes.""" + + class MockWeatherMockLegacyForecastOnly(WeatherPlatform.MockWeather): + """Mock weather class with mocked legacy forecast.""" + + def __init__(self, **values: Any) -> None: + """Initialize.""" + super().__init__(**values) + self.forecast_list: list[Forecast] | None = [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, + ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_UV_INDEX: self.uv_index, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + ATTR_FORECAST_HUMIDITY: self.humidity, + } + ] + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self.forecast_list + + # Fake that the class belongs to a custom integration + MockWeatherMockLegacyForecastOnly.__module__ = "custom_components.test.weather" + + kwargs = { + "native_temperature": 38, + "native_temperature_unit": UnitOfTemperature.CELSIUS, + } + weather_entity = MockWeatherMockLegacyForecastOnly( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, + ) + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setups(config_entry, [DOMAIN]) + return True + + async def async_setup_entry_weather_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test weather platform via config entry.""" + async_add_entities([weather_entity]) + + mock_integration( + hass, + MockModule( + "test", + async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, + ), + built_in=False, + ) + mock_platform( + hass, + "test.weather", + MockPlatform(async_setup_entry=async_setup_entry_weather_platform), + ) + + config_entry = MockConfigEntry(domain="test") + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert weather_entity.state == ATTR_CONDITION_SUNNY + + issues = ir.async_get(hass) + issue = issues.async_get_issue("weather", "deprecated_weather_forecast_test") + assert issue + assert issue.issue_domain == "test" + assert issue.issue_id == "deprecated_weather_forecast_test" + assert issue.translation_key == translation_key + assert ( + issue.translation_placeholders + == {"platform": "test"} | translation_placeholders_extra + ) + + assert ( + "test::MockWeatherMockLegacyForecastOnly implements the `forecast` property or " + "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " + f"and will be unsupported from Home Assistant 2024.3. Please {report}" + ) in caplog.text + + +async def test_issue_forecast_attr_deprecated( hass: HomeAssistant, enable_custom_integrations: None, caplog: pytest.LogCaptureFixture, @@ -964,14 +1128,14 @@ async def test_issue_forecast_deprecated( platform: WeatherPlatform = getattr(hass.components, "test.weather") caplog.clear() platform.init(empty=True) - platform.ENTITIES.append( - platform.MockWeatherMockLegacyForecastOnly( - name="Testing", - entity_id="weather.testing", - condition=ATTR_CONDITION_SUNNY, - **kwargs, - ) + weather = platform.MockWeather( + name="Testing", + entity_id="weather.testing", + condition=ATTR_CONDITION_SUNNY, + **kwargs, ) + weather._attr_forecast = [] + platform.ENTITIES.append(weather) entity0 = platform.ENTITIES[0] assert await async_setup_component( @@ -986,15 +1150,15 @@ async def test_issue_forecast_deprecated( assert issue assert issue.issue_domain == "test" assert issue.issue_id == "deprecated_weather_forecast_test" - assert issue.translation_placeholders == { - "platform": "test", - "report_issue": "report it to the custom integration author.", - } + assert issue.translation_key == "deprecated_weather_forecast_no_url" + assert issue.translation_placeholders == {"platform": "test"} assert ( - "custom_components.test.weather::weather.testing is using a forecast attribute on an instance of WeatherEntity" - in caplog.text - ) + "test::MockWeather implements the `forecast` property or " + "sets `self._attr_forecast` in a subclass of WeatherEntity, this is deprecated " + "and will be unsupported from Home Assistant 2024.3. Please report it to the " + "author of the 'test' custom integration" + ) in caplog.text async def test_issue_forecast_deprecated_no_logging( diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 84864c1dbb2..633a5e4c389 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -125,11 +125,6 @@ class MockWeather(MockEntity, WeatherEntity): """Return the unit of measurement for visibility.""" return self._handle("native_visibility_unit") - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self._handle("forecast") - @property def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" @@ -291,34 +286,3 @@ class MockWeatherMockForecast(MockWeather): ATTR_FORECAST_HUMIDITY: self.humidity, } ] - - -class MockWeatherMockLegacyForecastOnly(MockWeather): - """Mock weather class with mocked legacy forecast.""" - - def __init__(self, **values: Any) -> None: - """Initialize.""" - super().__init__(**values) - self.forecast_list: list[Forecast] | None = [ - { - ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, - ATTR_FORECAST_NATIVE_APPARENT_TEMP: self.native_apparent_temperature, - ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, - ATTR_FORECAST_NATIVE_DEW_POINT: self.native_dew_point, - ATTR_FORECAST_CLOUD_COVERAGE: self.cloud_coverage, - ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, - ATTR_FORECAST_NATIVE_WIND_GUST_SPEED: self.native_wind_gust_speed, - ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, - ATTR_FORECAST_WIND_BEARING: self.wind_bearing, - ATTR_FORECAST_UV_INDEX: self.uv_index, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( - "native_precipitation" - ), - ATTR_FORECAST_HUMIDITY: self.humidity, - } - ] - - @property - def forecast(self) -> list[Forecast] | None: - """Return the forecast.""" - return self.forecast_list From 589fd581371041984799240aeb090de67ca0830b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 19:57:21 +0200 Subject: [PATCH 187/968] Use loader.async_suggest_report_issue in stt (#101390) --- homeassistant/components/stt/__init__.py | 27 ++++++++++-------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index b1730a09357..c856a4817c9 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -26,6 +26,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util, language as language_util from .const import ( @@ -264,7 +265,7 @@ class SpeechToTextView(HomeAssistantView): raise HTTPBadRequest(text=str(err)) from err if not provider_entity: - stt_provider = self._get_provider(provider) + stt_provider = self._get_provider(hass, provider) # Check format if not stt_provider.check_metadata(metadata): @@ -297,7 +298,7 @@ class SpeechToTextView(HomeAssistantView): raise HTTPNotFound() if not provider_entity: - stt_provider = self._get_provider(provider) + stt_provider = self._get_provider(hass, provider) return self.json( { @@ -321,7 +322,7 @@ class SpeechToTextView(HomeAssistantView): } ) - def _get_provider(self, provider: str) -> Provider: + def _get_provider(self, hass: HomeAssistant, provider: str) -> Provider: """Get provider. Method for legacy providers. @@ -331,7 +332,7 @@ class SpeechToTextView(HomeAssistantView): if not self._legacy_provider_reported: self._legacy_provider_reported = True - report_issue = self._suggest_report_issue(provider, stt_provider) + report_issue = self._suggest_report_issue(hass, provider, stt_provider) # This should raise in Home Assistant Core 2023.9 _LOGGER.warning( "Provider %s (%s) is using a legacy implementation, " @@ -344,19 +345,13 @@ class SpeechToTextView(HomeAssistantView): return stt_provider - def _suggest_report_issue(self, provider: str, provider_instance: object) -> str: + def _suggest_report_issue( + self, hass: HomeAssistant, provider: str, provider_instance: object + ) -> str: """Suggest to report an issue.""" - report_issue = "" - if "custom_components" in type(provider_instance).__module__: - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - report_issue += f"+label%3A%22integration%3A+{provider}%22" - - return report_issue + return async_suggest_report_issue( + hass, integration_domain=provider, module=type(provider_instance).__module__ + ) def _metadata_from_header(request: web.Request) -> SpeechMetadata: From 716a10e5561b8220151e5977cb9d9365681d431e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 19:57:46 +0200 Subject: [PATCH 188/968] Add Python copyright and Python license to backports package (#101454) --- homeassistant/backports/LICENSE.Python | 279 +++++++++++++++++++++++++ homeassistant/backports/README | 5 + homeassistant/backports/functools.py | 10 + 3 files changed, 294 insertions(+) create mode 100644 homeassistant/backports/LICENSE.Python create mode 100644 homeassistant/backports/README diff --git a/homeassistant/backports/LICENSE.Python b/homeassistant/backports/LICENSE.Python new file mode 100644 index 00000000000..f26bcf4d2de --- /dev/null +++ b/homeassistant/backports/LICENSE.Python @@ -0,0 +1,279 @@ +A. HISTORY OF THE SOFTWARE +========================== + +Python was created in the early 1990s by Guido van Rossum at Stichting +Mathematisch Centrum (CWI, see https://www.cwi.nl) in the Netherlands +as a successor of a language called ABC. Guido remains Python's +principal author, although it includes many contributions from others. + +In 1995, Guido continued his work on Python at the Corporation for +National Research Initiatives (CNRI, see https://www.cnri.reston.va.us) +in Reston, Virginia where he released several versions of the +software. + +In May 2000, Guido and the Python core development team moved to +BeOpen.com to form the BeOpen PythonLabs team. In October of the same +year, the PythonLabs team moved to Digital Creations, which became +Zope Corporation. In 2001, the Python Software Foundation (PSF, see +https://www.python.org/psf/) was formed, a non-profit organization +created specifically to own Python-related Intellectual Property. +Zope Corporation was a sponsoring member of the PSF. + +All Python releases are Open Source (see https://opensource.org for +the Open Source Definition). Historically, most, but not all, Python +releases have also been GPL-compatible; the table below summarizes +the various releases. + + Release Derived Year Owner GPL- + from compatible? (1) + + 0.9.0 thru 1.2 1991-1995 CWI yes + 1.3 thru 1.5.2 1.2 1995-1999 CNRI yes + 1.6 1.5.2 2000 CNRI no + 2.0 1.6 2000 BeOpen.com no + 1.6.1 1.6 2001 CNRI yes (2) + 2.1 2.0+1.6.1 2001 PSF no + 2.0.1 2.0+1.6.1 2001 PSF yes + 2.1.1 2.1+2.0.1 2001 PSF yes + 2.1.2 2.1.1 2002 PSF yes + 2.1.3 2.1.2 2002 PSF yes + 2.2 and above 2.1.1 2001-now PSF yes + +Footnotes: + +(1) GPL-compatible doesn't mean that we're distributing Python under + the GPL. All Python licenses, unlike the GPL, let you distribute + a modified version without making your changes open source. The + GPL-compatible licenses make it possible to combine Python with + other software that is released under the GPL; the others don't. + +(2) According to Richard Stallman, 1.6.1 is not GPL-compatible, + because its license has a choice of law clause. According to + CNRI, however, Stallman's lawyer has told CNRI's lawyer that 1.6.1 + is "not incompatible" with the GPL. + +Thanks to the many outside volunteers who have worked under Guido's +direction to make these releases possible. + + +B. TERMS AND CONDITIONS FOR ACCESSING OR OTHERWISE USING PYTHON +=============================================================== + +Python software and documentation are licensed under the +Python Software Foundation License Version 2. + +Starting with Python 3.8.6, examples, recipes, and other code in +the documentation are dual licensed under the PSF License Version 2 +and the Zero-Clause BSD license. + +Some software incorporated into Python is under different licenses. +The licenses are listed with code falling under that license. + + +PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 +-------------------------------------------- + +1. This LICENSE AGREEMENT is between the Python Software Foundation +("PSF"), and the Individual or Organization ("Licensee") accessing and +otherwise using this software ("Python") in source or binary form and +its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, PSF hereby +grants Licensee a nonexclusive, royalty-free, world-wide license to reproduce, +analyze, test, perform and/or display publicly, prepare derivative works, +distribute, and otherwise use Python alone or in any derivative version, +provided, however, that PSF's License Agreement and PSF's notice of copyright, +i.e., "Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009, 2010, +2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023 Python Software Foundation; +All Rights Reserved" are retained in Python alone or in any derivative version +prepared by Licensee. + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python. + +4. PSF is making Python available to Licensee on an "AS IS" +basis. PSF MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, PSF MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. PSF SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. Nothing in this License Agreement shall be deemed to create any +relationship of agency, partnership, or joint venture between PSF and +Licensee. This License Agreement does not grant permission to use PSF +trademarks or trade name in a trademark sense to endorse or promote +products or services of Licensee, or any third party. + +8. By copying, installing or otherwise using Python, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +BEOPEN.COM LICENSE AGREEMENT FOR PYTHON 2.0 +------------------------------------------- + +BEOPEN PYTHON OPEN SOURCE LICENSE AGREEMENT VERSION 1 + +1. This LICENSE AGREEMENT is between BeOpen.com ("BeOpen"), having an +office at 160 Saratoga Avenue, Santa Clara, CA 95051, and the +Individual or Organization ("Licensee") accessing and otherwise using +this software in source or binary form and its associated +documentation ("the Software"). + +2. Subject to the terms and conditions of this BeOpen Python License +Agreement, BeOpen hereby grants Licensee a non-exclusive, +royalty-free, world-wide license to reproduce, analyze, test, perform +and/or display publicly, prepare derivative works, distribute, and +otherwise use the Software alone or in any derivative version, +provided, however, that the BeOpen Python License is retained in the +Software, alone or in any derivative version prepared by Licensee. + +3. BeOpen is making the Software available to Licensee on an "AS IS" +basis. BEOPEN MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, BEOPEN MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +4. BEOPEN SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF THE +SOFTWARE FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS +AS A RESULT OF USING, MODIFYING OR DISTRIBUTING THE SOFTWARE, OR ANY +DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +5. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +6. This License Agreement shall be governed by and interpreted in all +respects by the law of the State of California, excluding conflict of +law provisions. Nothing in this License Agreement shall be deemed to +create any relationship of agency, partnership, or joint venture +between BeOpen and Licensee. This License Agreement does not grant +permission to use BeOpen trademarks or trade names in a trademark +sense to endorse or promote products or services of Licensee, or any +third party. As an exception, the "BeOpen Python" logos available at +http://www.pythonlabs.com/logos.html may be used according to the +permissions granted on that web page. + +7. By copying, installing or otherwise using the software, Licensee +agrees to be bound by the terms and conditions of this License +Agreement. + + +CNRI LICENSE AGREEMENT FOR PYTHON 1.6.1 +--------------------------------------- + +1. This LICENSE AGREEMENT is between the Corporation for National +Research Initiatives, having an office at 1895 Preston White Drive, +Reston, VA 20191 ("CNRI"), and the Individual or Organization +("Licensee") accessing and otherwise using Python 1.6.1 software in +source or binary form and its associated documentation. + +2. Subject to the terms and conditions of this License Agreement, CNRI +hereby grants Licensee a nonexclusive, royalty-free, world-wide +license to reproduce, analyze, test, perform and/or display publicly, +prepare derivative works, distribute, and otherwise use Python 1.6.1 +alone or in any derivative version, provided, however, that CNRI's +License Agreement and CNRI's notice of copyright, i.e., "Copyright (c) +1995-2001 Corporation for National Research Initiatives; All Rights +Reserved" are retained in Python 1.6.1 alone or in any derivative +version prepared by Licensee. Alternately, in lieu of CNRI's License +Agreement, Licensee may substitute the following text (omitting the +quotes): "Python 1.6.1 is made available subject to the terms and +conditions in CNRI's License Agreement. This Agreement together with +Python 1.6.1 may be located on the internet using the following +unique, persistent identifier (known as a handle): 1895.22/1013. This +Agreement may also be obtained from a proxy server on the internet +using the following URL: http://hdl.handle.net/1895.22/1013". + +3. In the event Licensee prepares a derivative work that is based on +or incorporates Python 1.6.1 or any part thereof, and wants to make +the derivative work available to others as provided herein, then +Licensee hereby agrees to include in any such work a brief summary of +the changes made to Python 1.6.1. + +4. CNRI is making Python 1.6.1 available to Licensee on an "AS IS" +basis. CNRI MAKES NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR +IMPLIED. BY WAY OF EXAMPLE, BUT NOT LIMITATION, CNRI MAKES NO AND +DISCLAIMS ANY REPRESENTATION OR WARRANTY OF MERCHANTABILITY OR FITNESS +FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF PYTHON 1.6.1 WILL NOT +INFRINGE ANY THIRD PARTY RIGHTS. + +5. CNRI SHALL NOT BE LIABLE TO LICENSEE OR ANY OTHER USERS OF PYTHON +1.6.1 FOR ANY INCIDENTAL, SPECIAL, OR CONSEQUENTIAL DAMAGES OR LOSS AS +A RESULT OF MODIFYING, DISTRIBUTING, OR OTHERWISE USING PYTHON 1.6.1, +OR ANY DERIVATIVE THEREOF, EVEN IF ADVISED OF THE POSSIBILITY THEREOF. + +6. This License Agreement will automatically terminate upon a material +breach of its terms and conditions. + +7. This License Agreement shall be governed by the federal +intellectual property law of the United States, including without +limitation the federal copyright law, and, to the extent such +U.S. federal law does not apply, by the law of the Commonwealth of +Virginia, excluding Virginia's conflict of law provisions. +Notwithstanding the foregoing, with regard to derivative works based +on Python 1.6.1 that incorporate non-separable material that was +previously distributed under the GNU General Public License (GPL), the +law of the Commonwealth of Virginia shall govern this License +Agreement only as to issues arising under or with respect to +Paragraphs 4, 5, and 7 of this License Agreement. Nothing in this +License Agreement shall be deemed to create any relationship of +agency, partnership, or joint venture between CNRI and Licensee. This +License Agreement does not grant permission to use CNRI trademarks or +trade name in a trademark sense to endorse or promote products or +services of Licensee, or any third party. + +8. By clicking on the "ACCEPT" button where indicated, or by copying, +installing or otherwise using Python 1.6.1, Licensee agrees to be +bound by the terms and conditions of this License Agreement. + + ACCEPT + + +CWI LICENSE AGREEMENT FOR PYTHON 0.9.0 THROUGH 1.2 +-------------------------------------------------- + +Copyright (c) 1991 - 1995, Stichting Mathematisch Centrum Amsterdam, +The Netherlands. All rights reserved. + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose and without fee is hereby granted, +provided that the above copyright notice appear in all copies and that +both that copyright notice and this permission notice appear in +supporting documentation, and that the name of Stichting Mathematisch +Centrum or CWI not be used in advertising or publicity pertaining to +distribution of the software without specific, written prior +permission. + +STICHTING MATHEMATISCH CENTRUM DISCLAIMS ALL WARRANTIES WITH REGARD TO +THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS, IN NO EVENT SHALL STICHTING MATHEMATISCH CENTRUM BE LIABLE +FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +ZERO-CLAUSE BSD LICENSE FOR CODE IN THE PYTHON DOCUMENTATION +---------------------------------------------------------------------- + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/homeassistant/backports/README b/homeassistant/backports/README new file mode 100644 index 00000000000..9cc28864264 --- /dev/null +++ b/homeassistant/backports/README @@ -0,0 +1,5 @@ +This package contains backports of Python functionality from future Python +versions. + +Some of the backports have been copied directly from the CPython project, +and are subject to license agreement as detailed in LICENSE.Python. diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 212c8516b48..d8b26e38449 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -1,4 +1,14 @@ """Functools backports from standard lib.""" + +# This file contains parts of Python's module wrapper +# for the _functools C module +# to allow utilities written in Python to be added +# to the functools module. +# Written by Nick Coghlan , +# Raymond Hettinger , +# and Łukasz Langa . +# Copyright © 2001-2023 Python Software Foundation; All Rights Reserved + from __future__ import annotations from collections.abc import Callable From 659d437cac50a6dc1148e48cb843ccc54b4c8598 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 20:07:56 +0200 Subject: [PATCH 189/968] Use loader.async_suggest_report_issue in sensor (#101389) --- homeassistant/components/sensor/recorder.py | 17 ++++------------- tests/components/sensor/test_recorder.py | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 2ef1b6854fc..cb5a81d6b84 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -33,6 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources +from homeassistant.loader import async_suggest_report_issue from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum @@ -257,20 +258,10 @@ def _normalize_states( def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: """Suggest to report an issue.""" entity_info = entity_sources(hass).get(entity_id) - domain = entity_info["domain"] if entity_info else None - custom_component = entity_info["custom_component"] if entity_info else None - report_issue = "" - if custom_component: - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) - if domain: - report_issue += f"+label%3A%22integration%3A+{domain}%22" - return report_issue + return async_suggest_report_issue( + hass, integration_domain=entity_info["domain"] if entity_info else None + ) def warn_dip( diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 1c0200e1b53..34aaeda6740 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1337,7 +1337,7 @@ def test_compile_hourly_sum_statistics_nan_inf_state( "energy", 0, "from integration test ", - "report it to the custom integration author", + "report it to the author of the 'test' custom integration", ), ], ) From 62ea4b36cd6d3db2a72e22eb273d1f970eefd233 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 20:08:22 +0200 Subject: [PATCH 190/968] Use loader.async_suggest_report_issue in number (#101388) --- homeassistant/components/number/__init__.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 4e0f5059c90..ad2b7d55ff8 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -5,7 +5,6 @@ from collections.abc import Callable from contextlib import suppress import dataclasses from datetime import timedelta -import inspect import logging from math import ceil, floor from typing import Any, Self, final @@ -14,7 +13,8 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -23,6 +23,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_suggest_report_issue from .const import ( # noqa: F401 ATTR_MAX, @@ -192,14 +193,10 @@ class NumberEntity(Entity): "value", ) ): - module = inspect.getmodule(cls) - if module and module.__file__ and "custom_components" in module.__file__: - report_issue = "report it to the custom integration author." - else: - report_issue = ( - "create a bug report at " - "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" - ) + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue(hass, module=cls.__module__) _LOGGER.warning( ( "%s::%s is overriding deprecated methods on an instance of " From 285ad106247f5851fd962ee1b46f6b36241047bd Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 5 Oct 2023 20:09:52 +0200 Subject: [PATCH 191/968] Use snapshot in devolo_home_network update tests (#101442) --- .../snapshots/test_update.ambr | 54 +++++++++++++++++++ .../devolo_home_network/test_update.py | 25 +++------ 2 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 tests/components/devolo_home_network/snapshots/test_update.ambr diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr new file mode 100644 index 00000000000..e9872f5e1b5 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -0,0 +1,54 @@ +# serializer version: 1 +# name: test_update_firmware + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'auto_update': False, + 'device_class': 'firmware', + 'entity_picture': 'https://brands.home-assistant.io/_/devolo_home_network/icon.png', + 'friendly_name': 'Mock Title Firmware', + 'in_progress': False, + 'installed_version': '5.6.1', + 'latest_version': '5.6.2', + 'release_summary': None, + 'release_url': None, + 'skipped_version': None, + 'supported_features': , + 'title': None, + }), + 'context': , + 'entity_id': 'update.mock_title_firmware', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_update_firmware.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'update', + 'entity_category': , + 'entity_id': 'update.mock_title_firmware', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Firmware', + 'platform': 'devolo_home_network', + 'supported_features': , + 'translation_key': 'regular_firmware', + 'unique_id': '1234567890_regular_firmware', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index cb6de649e8e..2f8e3fcbc2e 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -3,25 +3,20 @@ from devolo_plc_api.device_api import UPDATE_NOT_AVAILABLE, UpdateFirmwareCheck from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable from freezegun.api import FrozenDateTimeFactory import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.devolo_home_network.const import ( DOMAIN, FIRMWARE_UPDATE_INTERVAL, ) -from homeassistant.components.update import ( - DOMAIN as PLATFORM, - SERVICE_INSTALL, - UpdateDeviceClass, -) +from homeassistant.components.update import DOMAIN as PLATFORM, SERVICE_INSTALL from homeassistant.config_entries import SOURCE_REAUTH -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import EntityCategory from . import configure_integration -from .const import FIRMWARE_UPDATE_AVAILABLE from .mock import MockDevice from tests.common import async_fire_time_changed @@ -45,6 +40,7 @@ async def test_update_firmware( mock_device: MockDevice, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, ) -> None: """Test updating a device.""" entry = configure_integration(hass) @@ -54,17 +50,8 @@ async def test_update_firmware( await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state = hass.states.get(state_key) - assert state is not None - assert state.state == STATE_ON - assert state.attributes["device_class"] == UpdateDeviceClass.FIRMWARE - assert state.attributes["installed_version"] == mock_device.firmware_version - assert ( - state.attributes["latest_version"] - == FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split("_")[0] - ) - - assert entity_registry.async_get(state_key).entity_category == EntityCategory.CONFIG + assert hass.states.get(state_key) == snapshot + assert entity_registry.async_get(state_key) == snapshot await hass.services.async_call( PLATFORM, From b7914582db071c9b6c8261c80d790425d32e13b5 Mon Sep 17 00:00:00 2001 From: mbo18 Date: Thu, 5 Oct 2023 20:10:27 +0200 Subject: [PATCH 192/968] Update homeassistant color (#101372) --- homeassistant/util/color.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index d9f2a4b96ff..8e7fc3dc155 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -172,7 +172,7 @@ COLORS = { "yellow": RGBColor(255, 255, 0), "yellowgreen": RGBColor(154, 205, 50), # And... - "homeassistant": RGBColor(3, 169, 244), + "homeassistant": RGBColor(24, 188, 242), } From a428bbfc2ed008180016b373280dfa5c0085a0f0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 5 Oct 2023 20:39:24 +0200 Subject: [PATCH 193/968] Use loader.async_suggest_report_issue in vacuum (#101391) --- homeassistant/components/vacuum/__init__.py | 33 +++++++++++++++----- homeassistant/components/vacuum/strings.json | 4 +++ tests/components/vacuum/test_init.py | 32 ++++++++++++++++++- 3 files changed, 60 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 68d50d1c2fc..c0680913df6 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -40,7 +40,11 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import ConfigType -from homeassistant.loader import bind_hass +from homeassistant.loader import ( + async_get_issue_tracker, + async_suggest_report_issue, + bind_hass, +) _LOGGER = logging.getLogger(__name__) @@ -384,6 +388,16 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): # we don't worry about demo and mqtt has it's own deprecation warnings. if self.platform.platform_name in ("demo", "mqtt"): return + translation_key = "deprecated_vacuum_base_class" + translation_placeholders = {"platform": self.platform.platform_name} + issue_tracker = async_get_issue_tracker( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, + ) + if issue_tracker: + translation_placeholders["issue_tracker"] = issue_tracker + translation_key = "deprecated_vacuum_base_class_url" ir.async_create_issue( hass, DOMAIN, @@ -393,21 +407,24 @@ class VacuumEntity(_BaseVacuum, ToggleEntity): is_persistent=False, issue_domain=self.platform.platform_name, severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_vacuum_base_class", - translation_placeholders={ - "platform": self.platform.platform_name, - }, + translation_key=translation_key, + translation_placeholders=translation_placeholders, + ) + + report_issue = async_suggest_report_issue( + hass, + integration_domain=self.platform.platform_name, + module=type(self).__module__, ) _LOGGER.warning( ( "%s::%s is extending the deprecated base class VacuumEntity instead of " "StateVacuumEntity, this is not valid and will be unsupported " - "from Home Assistant 2024.2. Please report it to the author of the '%s'" - " custom integration" + "from Home Assistant 2024.2. Please %s" ), self.platform.platform_name, self.__class__.__name__, - self.platform.platform_name, + report_issue, ) entity_description: VacuumEntityDescription diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index 73e50af5caa..3c018fc1a89 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -33,6 +33,10 @@ "deprecated_vacuum_base_class": { "title": "The {platform} custom integration is using deprecated vacuum feature", "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease report it to the author of the `{platform}` custom integration.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." + }, + "deprecated_vacuum_base_class_url": { + "title": "[%key:component::vacuum::issues::deprecated_vacuum_base_class::title%]", + "description": "The custom integration `{platform}` is extending the deprecated base class `VacuumEntity` instead of `StateVacuumEntity`.\n\nPlease create a bug report at {issue_tracker}.\n\nOnce an updated version of `{platform}` is available, install it and restart Home Assistant to fix this issue." } }, "services": { diff --git a/tests/components/vacuum/test_init.py b/tests/components/vacuum/test_init.py index eaa39bceaec..7c5c0de1674 100644 --- a/tests/components/vacuum/test_init.py +++ b/tests/components/vacuum/test_init.py @@ -36,8 +36,30 @@ def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: yield +ISSUE_TRACKER = "https://blablabla.com" + + +@pytest.mark.parametrize( + ("manifest_extra", "translation_key", "translation_placeholders_extra"), + [ + ( + {}, + "deprecated_vacuum_base_class", + {}, + ), + ( + {"issue_tracker": ISSUE_TRACKER}, + "deprecated_vacuum_base_class_url", + {"issue_tracker": ISSUE_TRACKER}, + ), + ], +) async def test_deprecated_base_class( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + manifest_extra: dict[str, str], + translation_key: str, + translation_placeholders_extra: dict[str, str], ) -> None: """Test warnings when adding VacuumEntity to the state machine.""" @@ -54,7 +76,9 @@ async def test_deprecated_base_class( MockModule( TEST_DOMAIN, async_setup_entry=async_setup_entry_init, + partial_manifest=manifest_extra, ), + built_in=False, ) entity1 = VacuumEntity() @@ -91,3 +115,9 @@ async def test_deprecated_base_class( VACUUM_DOMAIN, f"deprecated_vacuum_base_class_{TEST_DOMAIN}" ) assert issue.issue_domain == TEST_DOMAIN + assert issue.issue_id == f"deprecated_vacuum_base_class_{TEST_DOMAIN}" + assert issue.translation_key == translation_key + assert ( + issue.translation_placeholders + == {"platform": "test"} | translation_placeholders_extra + ) From b8fa0654672bb97cfa64a5b5a54e7f258824f7fb Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 5 Oct 2023 22:02:49 +0200 Subject: [PATCH 194/968] Update pyfibaro dependency to 0.7.5 (#101481) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index d90a9d28662..36cd4c9153f 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.2"] + "requirements": ["pyfibaro==0.7.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53ba0c208ff..aa150f7ac95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1701,7 +1701,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.2 +pyfibaro==0.7.5 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 69770e3338d..4bae48368ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1277,7 +1277,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.2 +pyfibaro==0.7.5 # homeassistant.components.fido pyfido==2.1.2 From ce55116eb27b33446f0dd33d619dc0d75958c871 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Thu, 5 Oct 2023 22:07:27 +0200 Subject: [PATCH 195/968] bump pywaze to 0.5.1 sets timeout to 60s (#101487) --- 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 1a4be798367..728a91e4933 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==0.5.0"] + "requirements": ["pywaze==0.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa150f7ac95..01d296fc22a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2246,7 +2246,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.5.0 +pywaze==0.5.1 # homeassistant.components.weatherflow pyweatherflowudp==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bae48368ef..20039a5dd14 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1672,7 +1672,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.5.0 +pywaze==0.5.1 # homeassistant.components.weatherflow pyweatherflowudp==1.4.3 From fe316f2233f112d84dbeadf873b8d78d62a43fe8 Mon Sep 17 00:00:00 2001 From: Ian Date: Thu, 5 Oct 2023 16:11:15 -0400 Subject: [PATCH 196/968] Fix key error in config flow when duplicate stop names exist (#101491) --- homeassistant/components/nextbus/config_flow.py | 2 +- tests/components/nextbus/conftest.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py index d7149bcc9f4..000dd86eb52 100644 --- a/homeassistant/components/nextbus/config_flow.py +++ b/homeassistant/components/nextbus/config_flow.py @@ -58,7 +58,7 @@ def _get_stop_tags( # Append directions for stops with shared titles for tag, title in tags.items(): if title_counts[title] > 1: - tags[tag] = f"{title} ({stop_directions[tag]})" + tags[tag] = f"{title} ({stop_directions.get(tag, tag)})" return tags diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py index a38f3fd850e..0940118c13a 100644 --- a/tests/components/nextbus/conftest.py +++ b/tests/components/nextbus/conftest.py @@ -19,6 +19,8 @@ def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: "stop": [ {"tag": "5650", "title": "Market St & 7th St"}, {"tag": "5651", "title": "Market St & 7th St"}, + # Error case test. Duplicate title with no unique direction + {"tag": "5652", "title": "Market St & 7th St"}, ], "direction": [ { From 6853d54050cbdeb3e96b658f845b1a8ebb471b39 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 5 Oct 2023 22:12:01 +0200 Subject: [PATCH 197/968] Remove logging of retrying config entry warning (#101483) --- homeassistant/config_entries.py | 31 +++++++++----------------- tests/components/nest/test_init.py | 8 +++---- tests/components/yeelight/test_init.py | 2 +- 3 files changed, 15 insertions(+), 26 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index ed5ba79c1b4..02a9dd9dade 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -437,27 +437,16 @@ class ConfigEntry: self._tries += 1 message = str(ex) ready_message = f"ready yet: {message}" if message else "ready yet" - if self._tries == 1: - _LOGGER.warning( - ( - "Config entry '%s' for %s integration not %s; Retrying in" - " background" - ), - self.title, - self.domain, - ready_message, - ) - else: - _LOGGER.debug( - ( - "Config entry '%s' for %s integration not %s; Retrying in %d" - " seconds" - ), - self.title, - self.domain, - ready_message, - wait_time, - ) + _LOGGER.debug( + ( + "Config entry '%s' for %s integration not %s; Retrying in %d" + " seconds" + ), + self.title, + self.domain, + ready_message, + wait_time, + ) if hass.state == CoreState.running: self._async_cancel_retry_setup = async_call_later( diff --git a/tests/components/nest/test_init.py b/tests/components/nest/test_init.py index ecfe412bdbf..1e3eed91f19 100644 --- a/tests/components/nest/test_init.py +++ b/tests/components/nest/test_init.py @@ -107,11 +107,11 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) async def test_setup_susbcriber_failure( - hass: HomeAssistant, warning_caplog, failing_subscriber, setup_base_platform + hass: HomeAssistant, caplog, failing_subscriber, setup_base_platform ) -> None: """Test configuration error.""" await setup_base_platform() - assert "Subscriber error:" in warning_caplog.text + assert "Subscriber error:" in caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -119,7 +119,7 @@ async def test_setup_susbcriber_failure( async def test_setup_device_manager_failure( - hass: HomeAssistant, warning_caplog, setup_base_platform + hass: HomeAssistant, caplog, setup_base_platform ) -> None: """Test device manager api failure.""" with patch( @@ -130,7 +130,7 @@ async def test_setup_device_manager_failure( ): await setup_base_platform() - assert "Device manager error:" in warning_caplog.text + assert "Device manager error:" in caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index b439ce04c25..fb9ecc9bea4 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -641,5 +641,5 @@ async def test_async_setup_retries_with_wrong_device( assert config_entry.state is ConfigEntryState.SETUP_RETRY assert ( "Unexpected device found at 192.168.1.239; expected 0x0000000000999999, " - "found 0x000000000015243f; Retrying in background" + "found 0x000000000015243f; Retrying in" ) in caplog.text From 8a033ee5544f5ed30b67a395525348f76101432d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 5 Oct 2023 22:17:09 +0200 Subject: [PATCH 198/968] Fix Trafikverket Camera if no location data (#101463) --- .../trafikverket_camera/config_flow.py | 8 +++- .../trafikverket_camera/conftest.py | 21 +++++++++++ .../trafikverket_camera/test_config_flow.py | 37 +++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index e1f8220c4ff..d4a282cb344 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -35,6 +35,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Validate input from user input.""" errors: dict[str, str] = {} camera_info: CameraInfo | None = None + camera_location: str | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) @@ -49,7 +50,12 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except UnknownError: errors["base"] = "cannot_connect" - camera_location = camera_info.location if camera_info else None + if camera_info: + if _location := camera_info.location: + camera_location = _location + else: + camera_location = camera_info.camera_name + return (errors, camera_location) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index fc6d70ae704..95c145bbeb3 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -67,3 +67,24 @@ def fixture_get_camera() -> CameraInfo: status="Running", camera_type="Road", ) + + +@pytest.fixture(name="get_camera_no_location") +def fixture_get_camera_no_location() -> CameraInfo: + """Construct Camera Mock.""" + + return CameraInfo( + camera_name="Test Camera", + camera_id="1234", + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location=None, + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ) diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index aa6122b7efe..ae3410d20b3 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -56,6 +56,43 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: assert result2["result"].unique_id == "trafikverket_camera-Test location" +async def test_form_no_location_data( + hass: HomeAssistant, get_camera_no_location: CameraInfo +) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + return_value=get_camera_no_location, + ), patch( + "homeassistant.components.trafikverket_camera.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_LOCATION: "Test Cam", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Test Camera" + assert result2["data"] == { + "api_key": "1234567890", + "location": "Test Camera", + } + assert len(mock_setup_entry.mock_calls) == 1 + assert result2["result"].unique_id == "trafikverket_camera-Test Camera" + + @pytest.mark.parametrize( ("side_effect", "error_key", "base_error"), [ From 1d31def9824841331bca8de698ac2d3f645f83b9 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 5 Oct 2023 23:46:07 +0200 Subject: [PATCH 199/968] Update nibe library to 2.4.0 (#101493) --- homeassistant/components/nibe_heatpump/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index f57a4511eec..355ce84525f 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.2.0"] + "requirements": ["nibe==2.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 01d296fc22a..3bd419d787a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1285,7 +1285,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.2.0 +nibe==2.4.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20039a5dd14..b4f7123377f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1002,7 +1002,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.2.0 +nibe==2.4.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 From c7d533d427b1c942c3885bc7d790218b81ff2c3b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 5 Oct 2023 22:38:15 -0700 Subject: [PATCH 200/968] Update fitbit error handling (#101304) * Update fitbit error handling * Update exceptions to inherit HomeAssistantError and add reason code * Revert config flow exception mapping hack --- homeassistant/components/fitbit/__init__.py | 12 +-- homeassistant/components/fitbit/api.py | 29 ++++-- .../fitbit/application_credentials.py | 47 ++++++---- .../components/fitbit/config_flow.py | 8 +- homeassistant/components/fitbit/exceptions.py | 14 +++ homeassistant/components/fitbit/sensor.py | 23 +++-- tests/components/fitbit/test_config_flow.py | 25 ++++- tests/components/fitbit/test_sensor.py | 94 +++++++++++++++++-- 8 files changed, 203 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/fitbit/exceptions.py diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index 522754de5d6..acf3014fb33 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -1,8 +1,5 @@ """The fitbit component.""" -from http import HTTPStatus - -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -12,6 +9,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN +from .exceptions import FitbitApiException, FitbitAuthException PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -31,11 +29,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) try: await fitbit_api.async_get_access_token() - except aiohttp.ClientResponseError as err: - if err.status == HTTPStatus.UNAUTHORIZED: - raise ConfigEntryAuthFailed from err - raise ConfigEntryNotReady from err - except aiohttp.ClientError as err: + except FitbitAuthException as err: + raise ConfigEntryAuthFailed from err + except FitbitApiException as err: raise ConfigEntryNotReady from err hass.data[DOMAIN][entry.entry_id] = fitbit_api diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index 9ebfbcf7188..dab64724e1c 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -1,10 +1,12 @@ """API for fitbit bound to Home Assistant OAuth.""" from abc import ABC, abstractmethod +from collections.abc import Callable import logging -from typing import Any, cast +from typing import Any, TypeVar, cast from fitbit import Fitbit +from fitbit.exceptions import HTTPException, HTTPUnauthorized from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant @@ -12,6 +14,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.unit_system import METRIC_SYSTEM from .const import FitbitUnitSystem +from .exceptions import FitbitApiException, FitbitAuthException from .model import FitbitDevice, FitbitProfile _LOGGER = logging.getLogger(__name__) @@ -20,6 +23,9 @@ CONF_REFRESH_TOKEN = "refresh_token" CONF_EXPIRES_AT = "expires_at" +_T = TypeVar("_T") + + class FitbitApi(ABC): """Fitbit client library wrapper base class. @@ -58,9 +64,7 @@ class FitbitApi(ABC): """Return the user profile from the API.""" if self._profile is None: client = await self._async_get_client() - response: dict[str, Any] = await self._hass.async_add_executor_job( - client.user_profile_get - ) + response: dict[str, Any] = await self._run(client.user_profile_get) _LOGGER.debug("user_profile_get=%s", response) profile = response["user"] self._profile = FitbitProfile( @@ -95,9 +99,7 @@ class FitbitApi(ABC): async def async_get_devices(self) -> list[FitbitDevice]: """Return available devices.""" client = await self._async_get_client() - devices: list[dict[str, str]] = await self._hass.async_add_executor_job( - client.get_devices - ) + devices: list[dict[str, str]] = await self._run(client.get_devices) _LOGGER.debug("get_devices=%s", devices) return [ FitbitDevice( @@ -120,12 +122,23 @@ class FitbitApi(ABC): def _time_series() -> dict[str, Any]: return cast(dict[str, Any], client.time_series(resource_type, period="7d")) - response: dict[str, Any] = await self._hass.async_add_executor_job(_time_series) + response: dict[str, Any] = await self._run(_time_series) _LOGGER.debug("time_series(%s)=%s", resource_type, response) key = resource_type.replace("/", "-") dated_results: list[dict[str, Any]] = response[key] return dated_results[-1] + async def _run(self, func: Callable[[], _T]) -> _T: + """Run client command.""" + try: + return await self._hass.async_add_executor_job(func) + except HTTPUnauthorized as err: + _LOGGER.debug("Unauthorized error from fitbit API: %s", err) + raise FitbitAuthException from err + except HTTPException as err: + _LOGGER.debug("Error from fitbit API: %s", err) + raise FitbitApiException from err + class OAuthFitbitApi(FitbitApi): """Provide fitbit authentication tied to an OAuth2 based config entry.""" diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index 95a7cf799bf..e66b9ca9014 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -5,9 +5,12 @@ details on Fitbit authorization. """ import base64 +from http import HTTPStatus import logging from typing import Any, cast +import aiohttp + from homeassistant.components.application_credentials import ( AuthImplementation, AuthorizationServer, @@ -18,6 +21,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .exceptions import FitbitApiException, FitbitAuthException _LOGGER = logging.getLogger(__name__) @@ -31,26 +35,37 @@ class FitbitOAuth2Implementation(AuthImplementation): async def async_resolve_external_data(self, external_data: dict[str, Any]) -> dict: """Resolve the authorization code to tokens.""" - session = async_get_clientsession(self.hass) - data = { - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - resp = await session.post(self.token_url, data=data, headers=self._headers) - resp.raise_for_status() - return cast(dict, await resp.json()) + return await self._post( + { + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) async def _token_request(self, data: dict) -> dict: """Make a token request.""" + return await self._post( + { + **data, + CONF_CLIENT_ID: self.client_id, + CONF_CLIENT_SECRET: self.client_secret, + } + ) + + async def _post(self, data: dict[str, Any]) -> dict[str, Any]: session = async_get_clientsession(self.hass) - body = { - **data, - CONF_CLIENT_ID: self.client_id, - CONF_CLIENT_SECRET: self.client_secret, - } - resp = await session.post(self.token_url, data=body, headers=self._headers) - resp.raise_for_status() + try: + resp = await session.post(self.token_url, data=data, headers=self._headers) + resp.raise_for_status() + except aiohttp.ClientResponseError as err: + error_body = await resp.text() + _LOGGER.debug("Client response error body: %s", error_body) + if err.status == HTTPStatus.UNAUTHORIZED: + raise FitbitAuthException from err + raise FitbitApiException from err + except aiohttp.ClientError as err: + raise FitbitApiException from err return cast(dict, await resp.json()) @property diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index ff9cf6cd17c..ee2340e7587 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -4,8 +4,6 @@ from collections.abc import Mapping import logging from typing import Any -from fitbit.exceptions import HTTPException - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TOKEN from homeassistant.data_entry_flow import FlowResult @@ -13,6 +11,7 @@ from homeassistant.helpers import config_entry_oauth2_flow from . import api from .const import DOMAIN, OAUTH_SCOPES +from .exceptions import FitbitApiException, FitbitAuthException _LOGGER = logging.getLogger(__name__) @@ -60,7 +59,10 @@ class OAuth2FlowHandler( client = api.ConfigFlowFitbitApi(self.hass, data[CONF_TOKEN]) try: profile = await client.async_get_user_profile() - except HTTPException as err: + except FitbitAuthException as err: + _LOGGER.error("Failed to authenticate with Fitbit API: %s", err) + return self.async_abort(reason="invalid_access_token") + except FitbitApiException as err: _LOGGER.error("Failed to fetch user profile for Fitbit API: %s", err) return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/fitbit/exceptions.py b/homeassistant/components/fitbit/exceptions.py new file mode 100644 index 00000000000..82ac53d5f99 --- /dev/null +++ b/homeassistant/components/fitbit/exceptions.py @@ -0,0 +1,14 @@ +"""Exceptions for fitbit API calls. + +These exceptions exist to provide common exceptions for the async and sync client libraries. +""" + +from homeassistant.exceptions import HomeAssistantError + + +class FitbitApiException(HomeAssistantError): + """Error talking to the fitbit API.""" + + +class FitbitAuthException(FitbitApiException): + """Authentication related error talking to the fitbit API.""" diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 313106a0d0f..c7c5e3258ed 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -59,6 +59,7 @@ from .const import ( FITBIT_DEFAULT_RESOURCES, FitbitUnitSystem, ) +from .exceptions import FitbitApiException from .model import FitbitDevice _LOGGER: Final = logging.getLogger(__name__) @@ -707,12 +708,22 @@ class FitbitSensor(SensorEntity): resource_type = self.entity_description.key if resource_type == "devices/battery" and self.device is not None: device_id = self.device.id - registered_devs: list[FitbitDevice] = await self.api.async_get_devices() - self.device = next( - device for device in registered_devs if device.id == device_id - ) - self._attr_native_value = self.device.battery + try: + registered_devs: list[FitbitDevice] = await self.api.async_get_devices() + except FitbitApiException: + self._attr_available = False + else: + self._attr_available = True + self.device = next( + device for device in registered_devs if device.id == device_id + ) + self._attr_native_value = self.device.battery + return - else: + try: result = await self.api.async_get_latest_time_series(resource_type) + except FitbitApiException: + self._attr_available = False + else: + self._attr_available = True self._attr_native_value = self.entity_description.value_fn(result) diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index df4bae89b47..e6ab39aff59 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +from typing import Any from unittest.mock import patch import pytest @@ -88,6 +89,20 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("http_status", "json", "error_reason"), + [ + (HTTPStatus.INTERNAL_SERVER_ERROR, None, "cannot_connect"), + (HTTPStatus.FORBIDDEN, None, "cannot_connect"), + ( + HTTPStatus.UNAUTHORIZED, + { + "errors": [{"errorType": "invalid_grant"}], + }, + "invalid_access_token", + ), + ], +) async def test_api_failure( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -95,6 +110,9 @@ async def test_api_failure( current_request_with_host: None, requests_mock: Mocker, setup_credentials: None, + http_status: HTTPStatus, + json: Any, + error_reason: str, ) -> None: """Test a failure to fetch the profile during the setup flow.""" result = await hass.config_entries.flow.async_init( @@ -126,12 +144,15 @@ async def test_api_failure( ) requests_mock.register_uri( - "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR + "GET", + PROFILE_API_URL, + status_code=http_status, + json=json, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result.get("type") == FlowResultType.ABORT - assert result.get("reason") == "cannot_connect" + assert result.get("reason") == error_reason async def test_config_entry_already_exists( diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 9e2089b959c..2eb29db43de 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -2,17 +2,25 @@ from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any import pytest +from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion from homeassistant.components.fitbit.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import async_update_entity -from .conftest import PROFILE_USER_ID, timeseries_response +from .conftest import ( + DEVICES_API_URL, + PROFILE_USER_ID, + TIMESERIES_API_URL_FORMAT, + timeseries_response, +) DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", @@ -359,7 +367,6 @@ async def test_activity_scope_config_entry( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], - entity_registry: er.EntityRegistry, ) -> None: """Test activity sensors are enabled.""" @@ -404,7 +411,6 @@ async def test_heartrate_scope_config_entry( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], - entity_registry: er.EntityRegistry, ) -> None: """Test heartrate sensors are enabled.""" @@ -429,7 +435,6 @@ async def test_sleep_scope_config_entry( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], - entity_registry: er.EntityRegistry, ) -> None: """Test sleep sensors are enabled.""" @@ -471,7 +476,6 @@ async def test_weight_scope_config_entry( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], - entity_registry: er.EntityRegistry, ) -> None: """Test sleep sensors are enabled.""" @@ -493,7 +497,6 @@ async def test_settings_scope_config_entry( setup_credentials: None, integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], - entity_registry: er.EntityRegistry, ) -> None: """Test heartrate sensors are enabled.""" @@ -510,3 +513,82 @@ async def test_settings_scope_config_entry( assert [s.entity_id for s in states] == [ "sensor.charge_2_battery", ] + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_failed( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test a failed sensor update when talking to the API.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "unavailable" + + +@pytest.mark.parametrize( + ("scopes", "mock_devices"), + [(["settings"], None)], +) +async def test_device_battery_level_update_failed( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test API failure for a battery level sensor for devices.""" + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + [ + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # A second spurious update request on startup + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # Fail when requesting an update + { + "status_code": HTTPStatus.INTERNAL_SERVER_ERROR, + "json": { + "errors": [ + { + "errorType": "request", + "message": "An error occurred", + } + ] + }, + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + + # Request an update for the entity which will fail + await async_update_entity(hass, "sensor.charge_2_battery") + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "unavailable" From da1d5fc8628c8c263bb94857910dc6f6a5f4b944 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Oct 2023 00:59:38 -0500 Subject: [PATCH 201/968] Increase max bind vars based on database version (#101464) --- homeassistant/components/recorder/const.py | 6 + homeassistant/components/recorder/core.py | 9 ++ .../components/recorder/migration.py | 16 ++- .../components/recorder/models/database.py | 1 + homeassistant/components/recorder/purge.py | 67 ++++++----- homeassistant/components/recorder/queries.py | 45 +++---- .../recorder/table_managers/event_data.py | 3 +- .../recorder/table_managers/event_types.py | 3 +- .../table_managers/state_attributes.py | 3 +- .../recorder/table_managers/states_meta.py | 3 +- homeassistant/components/recorder/util.py | 18 ++- tests/components/recorder/test_purge.py | 110 +++++++++++------- .../recorder/test_purge_v32_schema.py | 110 +++++++++--------- 13 files changed, 237 insertions(+), 157 deletions(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 7389cbf8ddf..dbfa1a2ff73 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -30,6 +30,12 @@ QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY = 0.65 # have upgraded their sqlite version SQLITE_MAX_BIND_VARS = 998 +# The maximum bind vars for sqlite 3.32.0 and above, but +# capped at 4000 to avoid performance issues +SQLITE_MODERN_MAX_BIND_VARS = 4000 + +DEFAULT_MAX_BIND_VARS = 4000 + DB_WORKER_PREFIX = "DbWorker" ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 0e926ad2a22..a8746a0a807 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -55,6 +55,7 @@ from .const import ( MYSQLDB_PYMYSQL_URL_PREFIX, MYSQLDB_URL_PREFIX, QUEUE_PERCENTAGE_ALLOWED_AVAILABLE_MEMORY, + SQLITE_MAX_BIND_VARS, SQLITE_URL_PREFIX, STATES_META_SCHEMA_VERSION, STATISTICS_ROWS_SCHEMA_VERSION, @@ -242,6 +243,13 @@ class Recorder(threading.Thread): self._dialect_name: SupportedDialect | None = None self.enabled = True + # For safety we default to the lowest value for max_bind_vars + # of all the DB types (SQLITE_MAX_BIND_VARS). + # + # We update the value once we connect to the DB + # and determine what is actually supported. + self.max_bind_vars = SQLITE_MAX_BIND_VARS + @property def backlog(self) -> int: """Return the number of items in the recorder backlog.""" @@ -1351,6 +1359,7 @@ class Recorder(threading.Thread): not self._completed_first_database_setup, ): self.database_engine = database_engine + self.max_bind_vars = database_engine.max_bind_vars self._completed_first_database_setup = True def _setup_connection(self) -> None: diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index f07e91ddaea..7655002b45f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1366,7 +1366,9 @@ def migrate_states_context_ids(instance: Recorder) -> bool: session_maker = instance.get_session _LOGGER.debug("Migrating states context_ids to binary format") with session_scope(session=session_maker()) as session: - if states := session.execute(find_states_context_ids_to_migrate()).all(): + if states := session.execute( + find_states_context_ids_to_migrate(instance.max_bind_vars) + ).all(): session.execute( update(States), [ @@ -1401,7 +1403,9 @@ def migrate_events_context_ids(instance: Recorder) -> bool: session_maker = instance.get_session _LOGGER.debug("Migrating context_ids to binary format") with session_scope(session=session_maker()) as session: - if events := session.execute(find_events_context_ids_to_migrate()).all(): + if events := session.execute( + find_events_context_ids_to_migrate(instance.max_bind_vars) + ).all(): session.execute( update(Events), [ @@ -1436,7 +1440,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool: _LOGGER.debug("Migrating event_types") event_type_manager = instance.event_type_manager with session_scope(session=session_maker()) as session: - if events := session.execute(find_event_type_to_migrate()).all(): + if events := session.execute( + find_event_type_to_migrate(instance.max_bind_vars) + ).all(): event_types = {event_type for _, event_type in events} if None in event_types: # event_type should never be None but we need to be defensive @@ -1505,7 +1511,9 @@ def migrate_entity_ids(instance: Recorder) -> bool: _LOGGER.debug("Migrating entity_ids") states_meta_manager = instance.states_meta_manager with session_scope(session=instance.get_session()) as session: - if states := session.execute(find_entity_ids_to_migrate()).all(): + if states := session.execute( + find_entity_ids_to_migrate(instance.max_bind_vars) + ).all(): entity_ids = {entity_id for _, entity_id in states} if None in entity_ids: # entity_id should never be None but we need to be defensive diff --git a/homeassistant/components/recorder/models/database.py b/homeassistant/components/recorder/models/database.py index e39f05cd9c5..a8c23d20061 100644 --- a/homeassistant/components/recorder/models/database.py +++ b/homeassistant/components/recorder/models/database.py @@ -18,6 +18,7 @@ class DatabaseEngine: dialect: SupportedDialect optimizer: DatabaseOptimizer + max_bind_vars: int version: AwesomeVersion | None diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 9dff59d1f59..8bc6584c5a1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -12,7 +12,6 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .const import SQLITE_MAX_BIND_VARS from .db_schema import Events, States, StatesMeta from .models import DatabaseEngine from .queries import ( @@ -72,7 +71,7 @@ def purge_old_data( purge_before.isoformat(sep=" ", timespec="seconds"), ) with session_scope(session=instance.get_session()) as session: - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states or events record + # Purge a max of max_bind_vars, based on the oldest states or events record has_more_to_purge = False if instance.use_legacy_events_index and _purging_legacy_format(session): _LOGGER.debug( @@ -93,9 +92,11 @@ def purge_old_data( instance, session, events_batch_size, purge_before ) - statistics_runs = _select_statistics_runs_to_purge(session, purge_before) + statistics_runs = _select_statistics_runs_to_purge( + session, purge_before, instance.max_bind_vars + ) short_term_statistics = _select_short_term_statistics_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) if statistics_runs: _purge_statistics_runs(session, statistics_runs) @@ -141,7 +142,7 @@ def _purge_legacy_format( attributes_ids, data_ids, ) = _select_legacy_event_state_and_attributes_and_data_ids_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) _purge_state_ids(instance, session, state_ids) _purge_unused_attributes_ids(instance, session, attributes_ids) @@ -157,7 +158,7 @@ def _purge_legacy_format( detached_state_ids, detached_attributes_ids, ) = _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( - session, purge_before + session, purge_before, instance.max_bind_vars ) _purge_state_ids(instance, session, detached_state_ids) _purge_unused_attributes_ids(instance, session, detached_attributes_ids) @@ -187,11 +188,12 @@ def _purge_states_and_attributes_ids( # There are more states relative to attributes_ids so # we purge enough state_ids to try to generate a full # size batch of attributes_ids that will be around the size - # SQLITE_MAX_BIND_VARS + # max_bind_vars attributes_ids_batch: set[int] = set() + max_bind_vars = instance.max_bind_vars for _ in range(states_batch_size): state_ids, attributes_ids = _select_state_attributes_ids_to_purge( - session, purge_before + session, purge_before, max_bind_vars ) if not state_ids: has_remaining_state_ids_to_purge = False @@ -221,10 +223,13 @@ def _purge_events_and_data_ids( # There are more events relative to data_ids so # we purge enough event_ids to try to generate a full # size batch of data_ids that will be around the size - # SQLITE_MAX_BIND_VARS + # max_bind_vars data_ids_batch: set[int] = set() + max_bind_vars = instance.max_bind_vars for _ in range(events_batch_size): - event_ids, data_ids = _select_event_data_ids_to_purge(session, purge_before) + event_ids, data_ids = _select_event_data_ids_to_purge( + session, purge_before, max_bind_vars + ) if not event_ids: has_remaining_event_ids_to_purge = False break @@ -240,13 +245,13 @@ def _purge_events_and_data_ids( def _select_state_attributes_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return sets of state and attribute ids to purge.""" state_ids = set() attributes_ids = set() for state_id, attributes_id in session.execute( - find_states_to_purge(dt_util.utc_to_timestamp(purge_before)) + find_states_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) ).all(): state_ids.add(state_id) if attributes_id: @@ -260,13 +265,13 @@ def _select_state_attributes_ids_to_purge( def _select_event_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return sets of event and data ids to purge.""" event_ids = set() data_ids = set() for event_id, data_id in session.execute( - find_events_to_purge(dt_util.utc_to_timestamp(purge_before)) + find_events_to_purge(dt_util.utc_to_timestamp(purge_before), max_bind_vars) ).all(): event_ids.add(event_id) if data_id: @@ -323,7 +328,7 @@ def _select_unused_attributes_ids( # # We used to generate a query based on how many attribute_ids to find but # that meant sqlalchemy Transparent SQL Compilation Caching was working against - # us by cached up to SQLITE_MAX_BIND_VARS different statements which could be + # us by cached up to max_bind_vars different statements which could be # up to 500MB for large database due to the complexity of the ORM objects. # # We now break the query into groups of 100 and use a lambda_stmt to ensure @@ -405,13 +410,15 @@ def _purge_unused_data_ids( def _select_statistics_runs_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> list[int]: """Return a list of statistic runs to purge. Takes care to keep the newest run. """ - statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all() + statistic_runs = session.execute( + find_statistics_runs_to_purge(purge_before, max_bind_vars) + ).all() statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( @@ -424,18 +431,18 @@ def _select_statistics_runs_to_purge( def _select_short_term_statistics_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> list[int]: """Return a list of short term statistics to purge.""" statistics = session.execute( - find_short_term_statistics_to_purge(purge_before) + find_short_term_statistics_to_purge(purge_before, max_bind_vars) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) return [statistic_id for (statistic_id,) in statistics] def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int]]: """Return a list of state, and attribute ids to purge. @@ -445,7 +452,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( """ states = session.execute( find_legacy_detached_states_and_attributes_to_purge( - dt_util.utc_to_timestamp(purge_before) + dt_util.utc_to_timestamp(purge_before), max_bind_vars ) ).all() _LOGGER.debug("Selected %s state ids to remove", len(states)) @@ -460,7 +467,7 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( - session: Session, purge_before: datetime + session: Session, purge_before: datetime, max_bind_vars: int ) -> tuple[set[int], set[int], set[int], set[int]]: """Return a list of event, state, and attribute ids to purge linked by the event_id. @@ -470,7 +477,7 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( """ events = session.execute( find_legacy_event_state_and_attributes_and_data_ids_to_purge( - dt_util.utc_to_timestamp(purge_before) + dt_util.utc_to_timestamp(purge_before), max_bind_vars ) ).all() _LOGGER.debug("Selected %s event ids to remove", len(events)) @@ -511,8 +518,8 @@ def _purge_state_ids(instance: Recorder, session: Session, state_ids: set[int]) def _purge_batch_attributes_ids( instance: Recorder, session: Session, attributes_ids: set[int] ) -> None: - """Delete old attributes ids in batches of SQLITE_MAX_BIND_VARS.""" - for attributes_ids_chunk in chunked(attributes_ids, SQLITE_MAX_BIND_VARS): + """Delete old attributes ids in batches of max_bind_vars.""" + for attributes_ids_chunk in chunked(attributes_ids, instance.max_bind_vars): deleted_rows = session.execute( delete_states_attributes_rows(attributes_ids_chunk) ) @@ -525,8 +532,8 @@ def _purge_batch_attributes_ids( def _purge_batch_data_ids( instance: Recorder, session: Session, data_ids: set[int] ) -> None: - """Delete old event data ids in batches of SQLITE_MAX_BIND_VARS.""" - for data_ids_chunk in chunked(data_ids, SQLITE_MAX_BIND_VARS): + """Delete old event data ids in batches of max_bind_vars.""" + for data_ids_chunk in chunked(data_ids, instance.max_bind_vars): deleted_rows = session.execute(delete_event_data_rows(data_ids_chunk)) _LOGGER.debug("Deleted %s data events", deleted_rows) @@ -671,7 +678,7 @@ def _purge_filtered_states( session.query(States.state_id, States.attributes_id, States.event_id) .filter(States.metadata_id.in_(metadata_ids_to_purge)) .filter(States.last_updated_ts < purge_before_timestamp) - .limit(SQLITE_MAX_BIND_VARS) + .limit(instance.max_bind_vars) .all() ) if not to_purge: @@ -709,7 +716,7 @@ def _purge_filtered_events( session.query(Events.event_id, Events.data_id) .filter(Events.event_type_id.in_(excluded_event_type_ids)) .filter(Events.time_fired_ts < purge_before_timestamp) - .limit(SQLITE_MAX_BIND_VARS) + .limit(instance.max_bind_vars) .all() ) if not to_purge: @@ -760,7 +767,7 @@ def purge_entity_data( if not selected_metadata_ids: return True - # Purge a max of SQLITE_MAX_BIND_VARS, based on the oldest states + # Purge a max of max_bind_vars, based on the oldest states # or events record. if not _purge_filtered_states( instance, diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 71a996f0381..d44094878c2 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -8,7 +8,6 @@ from sqlalchemy import delete, distinct, func, lambda_stmt, select, union_all, u from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select -from .const import SQLITE_MAX_BIND_VARS from .db_schema import ( EventData, Events, @@ -612,44 +611,48 @@ def delete_recorder_runs_rows( ) -def find_events_to_purge(purge_before: float) -> StatementLambdaElement: +def find_events_to_purge( + purge_before: float, max_bind_vars: int +) -> StatementLambdaElement: """Find events to purge.""" return lambda_stmt( lambda: select(Events.event_id, Events.data_id) .filter(Events.time_fired_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_states_to_purge(purge_before: float) -> StatementLambdaElement: +def find_states_to_purge( + purge_before: float, max_bind_vars: int +) -> StatementLambdaElement: """Find states to purge.""" return lambda_stmt( lambda: select(States.state_id, States.attributes_id) .filter(States.last_updated_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_short_term_statistics_to_purge( - purge_before: datetime, + purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: """Find short term statistics to purge.""" purge_before_ts = purge_before.timestamp() return lambda_stmt( lambda: select(StatisticsShortTerm.id) .filter(StatisticsShortTerm.start_ts < purge_before_ts) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_statistics_runs_to_purge( - purge_before: datetime, + purge_before: datetime, max_bind_vars: int ) -> StatementLambdaElement: """Find statistics_runs to purge.""" return lambda_stmt( lambda: select(StatisticsRuns.run_id) .filter(StatisticsRuns.start < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -659,7 +662,7 @@ def find_latest_statistics_runs_run_id() -> StatementLambdaElement: def find_legacy_event_state_and_attributes_and_data_ids_to_purge( - purge_before: float, + purge_before: float, max_bind_vars: int ) -> StatementLambdaElement: """Find the latest row in the legacy format to purge.""" return lambda_stmt( @@ -668,12 +671,12 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( ) .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired_ts < purge_before) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) def find_legacy_detached_states_and_attributes_to_purge( - purge_before: float, + purge_before: float, max_bind_vars: int ) -> StatementLambdaElement: """Find states rows with event_id set but not linked event_id in Events.""" return lambda_stmt( @@ -684,7 +687,7 @@ def find_legacy_detached_states_and_attributes_to_purge( (States.last_updated_ts < purge_before) | States.last_updated_ts.is_(None) ) .filter(Events.event_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -693,7 +696,7 @@ def find_legacy_row() -> StatementLambdaElement: return lambda_stmt(lambda: select(func.max(States.event_id))) -def find_events_context_ids_to_migrate() -> StatementLambdaElement: +def find_events_context_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( lambda: select( @@ -704,11 +707,11 @@ def find_events_context_ids_to_migrate() -> StatementLambdaElement: Events.context_parent_id, ) .filter(Events.context_id_bin.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_event_type_to_migrate() -> StatementLambdaElement: +def find_event_type_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events event_type to migrate.""" return lambda_stmt( lambda: select( @@ -716,11 +719,11 @@ def find_event_type_to_migrate() -> StatementLambdaElement: Events.event_type, ) .filter(Events.event_type_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) -def find_entity_ids_to_migrate() -> StatementLambdaElement: +def find_entity_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find entity_id to migrate.""" return lambda_stmt( lambda: select( @@ -728,7 +731,7 @@ def find_entity_ids_to_migrate() -> StatementLambdaElement: States.entity_id, ) .filter(States.metadata_id.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) @@ -792,7 +795,7 @@ def has_entity_ids_to_migrate() -> StatementLambdaElement: ) -def find_states_context_ids_to_migrate() -> StatementLambdaElement: +def find_states_context_ids_to_migrate(max_bind_vars: int) -> StatementLambdaElement: """Find events context_ids to migrate.""" return lambda_stmt( lambda: select( @@ -803,7 +806,7 @@ def find_states_context_ids_to_migrate() -> StatementLambdaElement: States.context_parent_id, ) .filter(States.context_id_bin.is_(None)) - .limit(SQLITE_MAX_BIND_VARS) + .limit(max_bind_vars) ) diff --git a/homeassistant/components/recorder/table_managers/event_data.py b/homeassistant/components/recorder/table_managers/event_data.py index 85266a37939..4c46b1b9faf 100644 --- a/homeassistant/components/recorder/table_managers/event_data.py +++ b/homeassistant/components/recorder/table_managers/event_data.py @@ -10,7 +10,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventData from ..queries import get_shared_event_datas from ..util import chunked, execute_stmt_lambda_element @@ -95,7 +94,7 @@ class EventDataManager(BaseLRUTableManager[EventData]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): for data_id, shared_data in execute_stmt_lambda_element( session, get_shared_event_datas(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/event_types.py b/homeassistant/components/recorder/table_managers/event_types.py index fd03bdd14d2..45b3b96353c 100644 --- a/homeassistant/components/recorder/table_managers/event_types.py +++ b/homeassistant/components/recorder/table_managers/event_types.py @@ -9,7 +9,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import EventTypes from ..queries import find_event_type_ids from ..tasks import RefreshEventTypesTask @@ -78,7 +77,7 @@ class EventTypeManager(BaseLRUTableManager[EventTypes]): return results with session.no_autoflush: - for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for missing_chunk in chunked(missing, self.recorder.max_bind_vars): for event_type_id, event_type in execute_stmt_lambda_element( session, find_event_type_ids(missing_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/state_attributes.py b/homeassistant/components/recorder/table_managers/state_attributes.py index 653ef1689bd..725bacae71c 100644 --- a/homeassistant/components/recorder/table_managers/state_attributes.py +++ b/homeassistant/components/recorder/table_managers/state_attributes.py @@ -11,7 +11,6 @@ from homeassistant.core import Event from homeassistant.helpers.entity import entity_sources from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StateAttributes from ..queries import get_shared_attributes from ..util import chunked, execute_stmt_lambda_element @@ -108,7 +107,7 @@ class StateAttributesManager(BaseLRUTableManager[StateAttributes]): """ results: dict[str, int | None] = {} with session.no_autoflush: - for hashs_chunk in chunked(hashes, SQLITE_MAX_BIND_VARS): + for hashs_chunk in chunked(hashes, self.recorder.max_bind_vars): for attributes_id, shared_attrs in execute_stmt_lambda_element( session, get_shared_attributes(hashs_chunk), orm_rows=False ): diff --git a/homeassistant/components/recorder/table_managers/states_meta.py b/homeassistant/components/recorder/table_managers/states_meta.py index b8f6204d318..9b7aa1f7f96 100644 --- a/homeassistant/components/recorder/table_managers/states_meta.py +++ b/homeassistant/components/recorder/table_managers/states_meta.py @@ -8,7 +8,6 @@ from sqlalchemy.orm.session import Session from homeassistant.core import Event -from ..const import SQLITE_MAX_BIND_VARS from ..db_schema import StatesMeta from ..queries import find_all_states_metadata_ids, find_states_metadata_ids from ..util import chunked, execute_stmt_lambda_element @@ -104,7 +103,7 @@ class StatesMetaManager(BaseLRUTableManager[StatesMeta]): update_cache = from_recorder or not self._did_first_load with session.no_autoflush: - for missing_chunk in chunked(missing, SQLITE_MAX_BIND_VARS): + for missing_chunk in chunked(missing, self.recorder.max_bind_vars): for metadata_id, entity_id in execute_stmt_lambda_element( session, find_states_metadata_ids(missing_chunk) ): diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index d438cbede9f..f94601bb2cb 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -31,7 +31,15 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, issue_registry as ir import homeassistant.util.dt as dt_util -from .const import DATA_INSTANCE, DOMAIN, SQLITE_URL_PREFIX, SupportedDialect +from .const import ( + DATA_INSTANCE, + DEFAULT_MAX_BIND_VARS, + DOMAIN, + SQLITE_MAX_BIND_VARS, + SQLITE_MODERN_MAX_BIND_VARS, + SQLITE_URL_PREFIX, + SupportedDialect, +) from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, @@ -87,6 +95,7 @@ MARIADB_WITH_FIXED_IN_QUERIES_108 = _simple_version("10.8.4") MIN_VERSION_MYSQL = _simple_version("8.0.0") MIN_VERSION_PGSQL = _simple_version("12.0") MIN_VERSION_SQLITE = _simple_version("3.31.0") +MIN_VERSION_SQLITE_MODERN_BIND_VARS = _simple_version("3.32.0") # This is the maximum time after the recorder ends the session @@ -471,6 +480,7 @@ def setup_connection_for_dialect( version: AwesomeVersion | None = None slow_range_in_select = False if dialect_name == SupportedDialect.SQLITE: + max_bind_vars = SQLITE_MAX_BIND_VARS if first_connection: old_isolation = dbapi_connection.isolation_level # type: ignore[attr-defined] dbapi_connection.isolation_level = None # type: ignore[attr-defined] @@ -488,6 +498,9 @@ def setup_connection_for_dialect( version or version_string, "SQLite", MIN_VERSION_SQLITE ) + if version and version > MIN_VERSION_SQLITE_MODERN_BIND_VARS: + max_bind_vars = SQLITE_MODERN_MAX_BIND_VARS + # The upper bound on the cache size is approximately 16MiB of memory execute_on_connection(dbapi_connection, "PRAGMA cache_size = -16384") @@ -506,6 +519,7 @@ def setup_connection_for_dialect( execute_on_connection(dbapi_connection, "PRAGMA foreign_keys=ON") elif dialect_name == SupportedDialect.MYSQL: + max_bind_vars = DEFAULT_MAX_BIND_VARS execute_on_connection(dbapi_connection, "SET session wait_timeout=28800") if first_connection: result = query_on_connection(dbapi_connection, "SELECT VERSION()") @@ -546,6 +560,7 @@ def setup_connection_for_dialect( # Ensure all times are using UTC to avoid issues with daylight savings execute_on_connection(dbapi_connection, "SET time_zone = '+00:00'") elif dialect_name == SupportedDialect.POSTGRESQL: + max_bind_vars = DEFAULT_MAX_BIND_VARS if first_connection: # server_version_num was added in 2006 result = query_on_connection(dbapi_connection, "SHOW server_version") @@ -566,6 +581,7 @@ def setup_connection_for_dialect( dialect=SupportedDialect(dialect_name), version=version, optimizer=DatabaseOptimizer(slow_range_in_select=slow_range_in_select), + max_bind_vars=max_bind_vars, ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 096108e0349..eedbd2c0e29 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,11 +10,7 @@ from sqlalchemy.exc import DatabaseError, OperationalError from sqlalchemy.orm.session import Session from homeassistant.components import recorder -from homeassistant.components.recorder import purge, queries -from homeassistant.components.recorder.const import ( - SQLITE_MAX_BIND_VARS, - SupportedDialect, -) +from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.db_schema import ( Events, EventTypes, @@ -71,6 +67,39 @@ def mock_use_sqlite(request): yield +async def test_purge_big_database( + async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant +) -> None: + """Test deleting 2/3 old states from a big database.""" + + instance = await async_setup_recorder_instance(hass) + + for _ in range(25): + await _add_test_states(hass, wait_recording_done=False) + await async_wait_recording_done(hass) + + with patch.object(instance, "max_bind_vars", 100), patch.object( + instance.database_engine, "max_bind_vars", 100 + ), session_scope(hass=hass) as session: + states = session.query(States) + state_attributes = session.query(StateAttributes) + assert states.count() == 150 + assert state_attributes.count() == 3 + + purge_before = dt_util.utcnow() - timedelta(days=4) + + finished = purge_old_data( + instance, + purge_before, + states_batch_size=1, + events_batch_size=1, + repack=False, + ) + assert not finished + assert states.count() == 50 + assert state_attributes.count() == 1 + + async def test_purge_old_states( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: @@ -628,7 +657,7 @@ async def test_purge_cutoff_date( service_data = {"keep_days": 2} # Force multiple purge batches to be run - rows = SQLITE_MAX_BIND_VARS + 1 + rows = 999 cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) await _add_db_entries(hass, cutoff, rows) @@ -1411,7 +1440,7 @@ async def test_purge_entities( assert states.count() == 0 -async def _add_test_states(hass: HomeAssistant): +async def _add_test_states(hass: HomeAssistant, wait_recording_done: bool = True): """Add multiple states to the db for testing.""" utcnow = dt_util.utcnow() five_days_ago = utcnow - timedelta(days=5) @@ -1421,24 +1450,26 @@ async def _add_test_states(hass: HomeAssistant): async def set_state(entity_id, state, **kwargs): """Set the state.""" hass.states.async_set(entity_id, state, **kwargs) - await hass.async_block_till_done() - await async_wait_recording_done(hass) + if wait_recording_done: + await hass.async_block_till_done() + await async_wait_recording_done(hass) - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - state = f"autopurgeme_{event_id}" - attributes = {"autopurgeme": True, **base_attributes} - elif event_id < 4: - timestamp = five_days_ago - state = f"purgeme_{event_id}" - attributes = {"purgeme": True, **base_attributes} - else: - timestamp = utcnow - state = f"dontpurgeme_{event_id}" - attributes = {"dontpurgeme": True, **base_attributes} + with freeze_time() as freezer: + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = f"autopurgeme_{event_id}" + attributes = {"autopurgeme": True, **base_attributes} + elif event_id < 4: + timestamp = five_days_ago + state = f"purgeme_{event_id}" + attributes = {"purgeme": True, **base_attributes} + else: + timestamp = utcnow + state = f"dontpurgeme_{event_id}" + attributes = {"dontpurgeme": True, **base_attributes} - with freeze_time(timestamp): + freezer.move_to(timestamp) await set_state("test.recorder2", state, attributes=attributes) @@ -1453,18 +1484,19 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): # thread as well can cause the test to fail await async_wait_recording_done(hass) - for _ in range(iterations): - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - event_type = "EVENT_TEST_AUTOPURGE" - elif event_id < 4: - timestamp = five_days_ago - event_type = "EVENT_TEST_PURGE" - else: - timestamp = utcnow - event_type = "EVENT_TEST" - with freeze_time(timestamp): + with freeze_time() as freezer: + for _ in range(iterations): + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + event_type = "EVENT_TEST_AUTOPURGE" + elif event_id < 4: + timestamp = five_days_ago + event_type = "EVENT_TEST_PURGE" + else: + timestamp = utcnow + event_type = "EVENT_TEST" + freezer.move_to(timestamp) hass.bus.async_fire(event_type, event_data) await async_wait_recording_done(hass) @@ -1605,11 +1637,11 @@ async def test_purge_many_old_events( ) -> None: """Test deleting old events.""" old_events_count = 5 - with patch.object(queries, "SQLITE_MAX_BIND_VARS", old_events_count), patch.object( - purge, "SQLITE_MAX_BIND_VARS", old_events_count - ): - instance = await async_setup_recorder_instance(hass) + instance = await async_setup_recorder_instance(hass) + with patch.object(instance, "max_bind_vars", old_events_count), patch.object( + instance.database_engine, "max_bind_vars", old_events_count + ): await _add_test_events(hass, old_events_count) with session_scope(hass=hass) as session: diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 3b315481f4e..b3c20ad4e26 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -13,10 +13,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder import migration -from homeassistant.components.recorder.const import ( - SQLITE_MAX_BIND_VARS, - SupportedDialect, -) +from homeassistant.components.recorder.const import SupportedDialect from homeassistant.components.recorder.history import get_significant_states from homeassistant.components.recorder.purge import purge_old_data from homeassistant.components.recorder.services import ( @@ -631,7 +628,7 @@ async def test_purge_cutoff_date( service_data = {"keep_days": 2} # Force multiple purge batches to be run - rows = SQLITE_MAX_BIND_VARS + 1 + rows = 999 cutoff = dt_util.utcnow() - timedelta(days=service_data["keep_days"]) await _add_db_entries(hass, cutoff, rows) @@ -718,21 +715,22 @@ async def _add_test_states(hass: HomeAssistant): await hass.async_block_till_done() await async_wait_recording_done(hass) - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - state = f"autopurgeme_{event_id}" - attributes = {"autopurgeme": True, **base_attributes} - elif event_id < 4: - timestamp = five_days_ago - state = f"purgeme_{event_id}" - attributes = {"purgeme": True, **base_attributes} - else: - timestamp = utcnow - state = f"dontpurgeme_{event_id}" - attributes = {"dontpurgeme": True, **base_attributes} + with freeze_time() as freezer: + for event_id in range(6): + if event_id < 2: + timestamp = eleven_days_ago + state = f"autopurgeme_{event_id}" + attributes = {"autopurgeme": True, **base_attributes} + elif event_id < 4: + timestamp = five_days_ago + state = f"purgeme_{event_id}" + attributes = {"purgeme": True, **base_attributes} + else: + timestamp = utcnow + state = f"dontpurgeme_{event_id}" + attributes = {"dontpurgeme": True, **base_attributes} - with freeze_time(timestamp): + freezer.move_to(timestamp) await set_state("test.recorder2", state, attributes=attributes) @@ -952,46 +950,50 @@ async def test_purge_many_old_events( instance = await async_setup_recorder_instance(hass) await _async_attach_db_engine(hass) - await _add_test_events(hass, SQLITE_MAX_BIND_VARS) + old_events_count = 5 + with patch.object(instance, "max_bind_vars", old_events_count), patch.object( + instance.database_engine, "max_bind_vars", old_events_count + ): + await _add_test_events(hass, old_events_count) - with session_scope(hass=hass) as session: - events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) - assert events.count() == SQLITE_MAX_BIND_VARS * 6 + with session_scope(hass=hass) as session: + events = session.query(Events).filter(Events.event_type.like("EVENT_TEST%")) + assert events.count() == old_events_count * 6 - purge_before = dt_util.utcnow() - timedelta(days=4) + purge_before = dt_util.utcnow() - timedelta(days=4) - # run purge_old_data() - finished = purge_old_data( - instance, - purge_before, - repack=False, - states_batch_size=3, - events_batch_size=3, - ) - assert not finished - assert events.count() == SQLITE_MAX_BIND_VARS * 3 + # run purge_old_data() + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, + ) + assert not finished + assert events.count() == old_events_count * 3 - # we should only have 2 groups of events left - finished = purge_old_data( - instance, - purge_before, - repack=False, - states_batch_size=3, - events_batch_size=3, - ) - assert finished - assert events.count() == SQLITE_MAX_BIND_VARS * 2 + # we should only have 2 groups of events left + finished = purge_old_data( + instance, + purge_before, + repack=False, + states_batch_size=3, + events_batch_size=3, + ) + assert finished + assert events.count() == old_events_count * 2 - # we should now purge everything - finished = purge_old_data( - instance, - dt_util.utcnow(), - repack=False, - states_batch_size=20, - events_batch_size=20, - ) - assert finished - assert events.count() == 0 + # we should now purge everything + finished = purge_old_data( + instance, + dt_util.utcnow(), + repack=False, + states_batch_size=20, + events_batch_size=20, + ) + assert finished + assert events.count() == 0 async def test_purge_can_mix_legacy_and_new_format( From da770df13f2cb4782798856be7a17f9cfa1d8f6f Mon Sep 17 00:00:00 2001 From: Matteo Gheza Date: Fri, 6 Oct 2023 08:24:30 +0200 Subject: [PATCH 202/968] Change OpenWeatherMap unit_of_measurement from mm to mm/h (#101485) Change OWM unit_of_measurement --- homeassistant/components/openweathermap/sensor.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 232664d5b6b..a1e0e9d2169 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( UnitOfPressure, UnitOfSpeed, UnitOfTemperature, + UnitOfVolumetricFlux, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -123,15 +124,15 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_RAIN, name="Rain", - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, - device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_SNOW, name="Snow", - native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, - device_class=SensorDeviceClass.PRECIPITATION, + native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, + device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( From 2bfb1e75d32d9a4b2238a556fc20cbd50fbce36c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 6 Oct 2023 09:11:50 +0200 Subject: [PATCH 203/968] Correct device_class test for mqtt button (#101500) --- tests/components/mqtt/test_button.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 481e98f0099..35b6561895d 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -443,7 +443,7 @@ async def test_entity_debug_info_message( mqtt.DOMAIN: { button.DOMAIN: { "name": "test", - "state_topic": "test-topic", + "command_topic": "test-topic", "device_class": "foobarnotreal", } } @@ -451,11 +451,14 @@ async def test_entity_debug_info_message( ], ) async def test_invalid_device_class( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" with pytest.raises(AssertionError): await mqtt_mock_entry() + assert "expected ButtonDeviceClass" in caplog.text @pytest.mark.parametrize( From dd8bd0db5a8098cef60ecb53e3c8feb5a65faeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Fri, 6 Oct 2023 09:13:39 +0200 Subject: [PATCH 204/968] SMA add missing entity descriptions (#101462) --- homeassistant/components/sma/sensor.py | 38 ++++++++++++++++++++++++++ tests/components/sma/test_sensor.py | 20 ++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 11ed720b51c..f0fc475e0db 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -139,6 +139,13 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), + "pv_isolation_resistance": SensorEntityDescription( + key="pv_isolation_resistance", + name="PV Isolation Resistance", + native_unit_of_measurement="kOhms", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "insulation_residual_current": SensorEntityDescription( key="insulation_residual_current", name="Insulation Residual Current", @@ -147,6 +154,13 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, ), + "pv_power": SensorEntityDescription( + key="pv_power", + name="PV Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), "grid_power": SensorEntityDescription( key="grid_power", name="Grid Power", @@ -479,6 +493,30 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { state_class=SensorStateClass.TOTAL_INCREASING, device_class=SensorDeviceClass.ENERGY, ), + "sps_voltage": SensorEntityDescription( + key="sps_voltage", + name="Secure Power Supply Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "sps_current": SensorEntityDescription( + key="sps_current", + name="Secure Power Supply Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "sps_power": SensorEntityDescription( + key="sps_power", + name="Secure Power Supply Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), "optimizer_power": SensorEntityDescription( key="optimizer_power", name="Optimizer Power", diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index a79588f4800..acc26a8bf90 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -1,4 +1,12 @@ """Test the sma sensor platform.""" +from pysma.const import ( + ENERGY_METER_VIA_INVERTER, + GENERIC_SENSORS, + OPTIMIZERS_VIA_INVERTER, +) +from pysma.definitions import sensor_map + +from homeassistant.components.sma.sensor import SENSOR_ENTITIES from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, UnitOfPower from homeassistant.core import HomeAssistant @@ -8,3 +16,15 @@ async def test_sensors(hass: HomeAssistant, init_integration) -> None: state = hass.states.get("sensor.sma_device_grid_power") assert state assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT + + +async def test_sensor_entities(hass: HomeAssistant, init_integration) -> None: + """Test SENSOR_ENTITIES contains a SensorEntityDescription for each pysma sensor.""" + pysma_sensor_definitions = ( + sensor_map[GENERIC_SENSORS] + + sensor_map[OPTIMIZERS_VIA_INVERTER] + + sensor_map[ENERGY_METER_VIA_INVERTER] + ) + + for sensor in pysma_sensor_definitions: + assert sensor.name in SENSOR_ENTITIES From 442005e40f81735ee95ced3e766b549ea3e37ce6 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 6 Oct 2023 10:15:25 +0300 Subject: [PATCH 205/968] Add codeowner for Aranet (#101496) --- CODEOWNERS | 4 ++-- homeassistant/components/aranet/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 661d21fd95c..53a96111f88 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -100,8 +100,8 @@ build.json @home-assistant/supervisor /tests/components/apprise/ @caronc /homeassistant/components/aprs/ @PhilRW /tests/components/aprs/ @PhilRW -/homeassistant/components/aranet/ @aschmitz -/tests/components/aranet/ @aschmitz +/homeassistant/components/aranet/ @aschmitz @thecode +/tests/components/aranet/ @aschmitz @thecode /homeassistant/components/arcam_fmj/ @elupus /tests/components/arcam_fmj/ @elupus /homeassistant/components/arris_tg2492lg/ @vanbalken diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 03dc0995c1c..589cdc56e85 100644 --- a/homeassistant/components/aranet/manifest.json +++ b/homeassistant/components/aranet/manifest.json @@ -13,7 +13,7 @@ "connectable": false } ], - "codeowners": ["@aschmitz"], + "codeowners": ["@aschmitz", "@thecode"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/aranet", From 920bd040992f4d62c1ea56194d92662f472cc558 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Oct 2023 00:16:06 -0700 Subject: [PATCH 206/968] Fix for rainbird unique id (#101512) --- homeassistant/components/rainbird/calendar.py | 2 +- homeassistant/components/rainbird/number.py | 2 +- homeassistant/components/rainbird/sensor.py | 2 +- homeassistant/components/rainbird/switch.py | 7 ++-- tests/components/rainbird/test_switch.py | 34 ++++++++++++++++++- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/rainbird/calendar.py b/homeassistant/components/rainbird/calendar.py index 356f7d7cc4e..2001a14ac93 100644 --- a/homeassistant/components/rainbird/calendar.py +++ b/homeassistant/components/rainbird/calendar.py @@ -61,7 +61,7 @@ class RainBirdCalendarEntity( """Create the Calendar event device.""" super().__init__(coordinator) self._event: CalendarEvent | None = None - if unique_id: + if unique_id is not None: self._attr_unique_id = unique_id self._attr_device_info = device_info else: diff --git a/homeassistant/components/rainbird/number.py b/homeassistant/components/rainbird/number.py index 1e72fabafcd..dd9664222b2 100644 --- a/homeassistant/components/rainbird/number.py +++ b/homeassistant/components/rainbird/number.py @@ -51,7 +51,7 @@ class RainDelayNumber(CoordinatorEntity[RainbirdUpdateCoordinator], NumberEntity ) -> None: """Initialize the Rain Bird sensor.""" super().__init__(coordinator) - if coordinator.unique_id: + if coordinator.unique_id is not None: self._attr_unique_id = f"{coordinator.unique_id}-rain-delay" self._attr_device_info = coordinator.device_info else: diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py index d44e7156cb5..84bf8cadb7b 100644 --- a/homeassistant/components/rainbird/sensor.py +++ b/homeassistant/components/rainbird/sensor.py @@ -52,7 +52,7 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], SensorEntity) """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - if coordinator.unique_id: + if coordinator.unique_id is not None: self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" self._attr_device_info = coordinator.device_info else: diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index 62b3b0e9a8c..da3979a27fd 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -65,17 +65,18 @@ class RainBirdSwitch(CoordinatorEntity[RainbirdUpdateCoordinator], SwitchEntity) """Initialize a Rain Bird Switch Device.""" super().__init__(coordinator) self._zone = zone - if coordinator.unique_id: + _LOGGER.debug("coordinator.unique_id=%s", coordinator.unique_id) + if coordinator.unique_id is not None: self._attr_unique_id = f"{coordinator.unique_id}-{zone}" device_name = f"{MANUFACTURER} Sprinkler {zone}" if imported_name: self._attr_name = imported_name self._attr_has_entity_name = False else: - self._attr_name = None if coordinator.unique_id else device_name + self._attr_name = None if coordinator.unique_id is not None else device_name self._attr_has_entity_name = True self._duration_minutes = duration_minutes - if coordinator.unique_id and self._attr_unique_id: + if coordinator.unique_id is not None and self._attr_unique_id is not None: self._attr_device_info = DeviceInfo( name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}, diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 46a875e8928..31b64dded99 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -17,6 +17,7 @@ from .conftest import ( PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, + SERIAL_NUMBER, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -286,7 +287,7 @@ async def test_switch_error( @pytest.mark.parametrize( ("config_entry_unique_id"), [ - None, + (None), ], ) async def test_no_unique_id( @@ -307,3 +308,34 @@ async def test_no_unique_id( entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") assert entity_entry is None + + +@pytest.mark.parametrize( + ("config_entry_unique_id", "entity_unique_id"), + [ + (SERIAL_NUMBER, "1263613994342-3"), + # Some existing config entries may have a "0" serial number but preserve + # their unique id + (0, "0-3"), + ], +) +async def test_has_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + responses: list[AiohttpClientMockResponse], + entity_registry: er.EntityRegistry, + entity_unique_id: str, +) -> None: + """Test an irrigation switch with no unique id.""" + + assert await setup_integration() + + zone = hass.states.get("switch.rain_bird_sprinkler_3") + assert zone is not None + assert zone.attributes.get("friendly_name") == "Rain Bird Sprinkler 3" + assert zone.state == "off" + + entity_entry = entity_registry.async_get("switch.rain_bird_sprinkler_3") + assert entity_entry + assert entity_entry.unique_id == entity_unique_id From 48a23798d0e427260fb6ba26104e5f79f3162149 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Oct 2023 02:18:05 -0500 Subject: [PATCH 207/968] Fix caching of latest short term stats after insertion of external stats (#101490) --- .../components/recorder/statistics.py | 27 ++++++++++++++++--- .../components/recorder/test_websocket_api.py | 17 ++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 24fb209ae07..0ea16e09df4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -1924,7 +1924,13 @@ def get_latest_short_term_statistics( for metadata_id in missing_metadata_ids if ( latest_id := cache_latest_short_term_statistic_id_for_metadata_id( - run_cache, session, metadata_id + # orm_rows=False is used here because we are in + # a read-only session, and there will never be + # any pending inserts in the session. + run_cache, + session, + metadata_id, + orm_rows=False, ) ) is not None @@ -2310,8 +2316,14 @@ def _import_statistics_with_session( # We just inserted new short term statistics, so we need to update the # ShortTermStatisticsRunCache with the latest id for the metadata_id run_cache = get_short_term_statistics_run_cache(instance.hass) + # + # Because we are in the same session and we want to read rows + # that have not been flushed yet, we need to pass orm_rows=True + # to cache_latest_short_term_statistic_id_for_metadata_id + # to ensure that it gets the rows that were just inserted + # cache_latest_short_term_statistic_id_for_metadata_id( - run_cache, session, metadata_id + run_cache, session, metadata_id, orm_rows=True ) return True @@ -2326,7 +2338,10 @@ def get_short_term_statistics_run_cache( def cache_latest_short_term_statistic_id_for_metadata_id( - run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int + run_cache: ShortTermStatisticsRunCache, + session: Session, + metadata_id: int, + orm_rows: bool, ) -> int | None: """Cache the latest short term statistic for a given metadata_id. @@ -2339,7 +2354,11 @@ def cache_latest_short_term_statistic_id_for_metadata_id( execute_stmt_lambda_element( session, _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id), - orm_rows=False, + orm_rows=orm_rows + # _import_statistics_with_session needs to be able + # to read back the rows it just inserted without + # a flush so we have to pass orm_rows so we get + # back the latest data. ), ): id_: int = latest[0].id diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 38b657945f7..969fdd63ae5 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -14,6 +14,7 @@ from homeassistant.components.recorder.db_schema import Statistics, StatisticsSh from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, + get_latest_short_term_statistics, get_metadata, get_short_term_statistics_run_cache, list_statistic_ids, @@ -635,6 +636,22 @@ async def test_statistic_during_period( "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) * 1000, } + stats = get_latest_short_term_statistics( + hass, {"sensor.test"}, {"last_reset", "max", "mean", "min", "state", "sum"} + ) + start = imported_stats_5min[-1]["start"].timestamp() + end = start + (5 * 60) + assert stats == { + "sensor.test": [ + { + "end": end, + "last_reset": None, + "start": start, + "state": None, + "sum": 38.0, + } + ] + } @pytest.mark.freeze_time(datetime.datetime(2022, 10, 21, 7, 25, tzinfo=datetime.UTC)) From 244f6d800220f8aef2e2b4bf0eb2d2ae12a6393f Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 6 Oct 2023 02:18:35 -0500 Subject: [PATCH 208/968] Add wake word cooldown to avoid duplicate wake-ups (#101417) --- .../components/assist_pipeline/__init__.py | 6 +- .../components/assist_pipeline/const.py | 9 ++ .../components/assist_pipeline/pipeline.py | 29 ++++++- .../assist_pipeline/websocket_api.py | 7 +- .../assist_pipeline/snapshots/test_init.ambr | 2 + .../snapshots/test_websocket.ambr | 62 ++++++++++++++ tests/components/assist_pipeline/test_init.py | 16 ++-- .../assist_pipeline/test_websocket.py | 85 ++++++++++++++++++- 8 files changed, 198 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 9a61346f673..fab4c3178bc 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import DATA_CONFIG, DOMAIN +from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DOMAIN from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -45,7 +45,9 @@ __all__ = ( CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - {vol.Optional("debug_recording_dir"): str}, + { + vol.Optional(CONF_DEBUG_RECORDING_DIR): str, + }, ) }, extra=vol.ALLOW_EXTRA, diff --git a/homeassistant/components/assist_pipeline/const.py b/homeassistant/components/assist_pipeline/const.py index e21d9003a69..84b49fc18fa 100644 --- a/homeassistant/components/assist_pipeline/const.py +++ b/homeassistant/components/assist_pipeline/const.py @@ -2,3 +2,12 @@ DOMAIN = "assist_pipeline" DATA_CONFIG = f"{DOMAIN}.config" + +DEFAULT_PIPELINE_TIMEOUT = 60 * 5 # seconds + +DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds + +CONF_DEBUG_RECORDING_DIR = "debug_recording_dir" + +DATA_LAST_WAKE_UP = f"{DOMAIN}.last_wake_up" +DEFAULT_WAKE_WORD_COOLDOWN = 2 # seconds diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 76444fb2436..6ec031baf3b 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -48,7 +48,13 @@ from homeassistant.util import ( ) from homeassistant.util.limited_size_dict import LimitedSizeDict -from .const import DATA_CONFIG, DOMAIN +from .const import ( + CONF_DEBUG_RECORDING_DIR, + DATA_CONFIG, + DATA_LAST_WAKE_UP, + DEFAULT_WAKE_WORD_COOLDOWN, + DOMAIN, +) from .error import ( IntentRecognitionError, PipelineError, @@ -399,6 +405,9 @@ class WakeWordSettings: audio_seconds_to_buffer: float = 0 """Seconds of audio to buffer before detection and forward to STT.""" + cooldown_seconds: float = DEFAULT_WAKE_WORD_COOLDOWN + """Seconds after a wake word detection where other detections are ignored.""" + @dataclass(frozen=True) class AudioSettings: @@ -603,6 +612,8 @@ class PipelineRun: ) ) + wake_word_settings = self.wake_word_settings or WakeWordSettings() + # Remove language since it doesn't apply to wake words yet metadata_dict.pop("language", None) @@ -612,6 +623,7 @@ class PipelineRun: { "entity_id": self.wake_word_entity_id, "metadata": metadata_dict, + "timeout": wake_word_settings.timeout or 0, }, ) ) @@ -619,8 +631,6 @@ class PipelineRun: if self.debug_recording_queue is not None: self.debug_recording_queue.put_nowait(f"00_wake-{self.wake_word_entity_id}") - wake_word_settings = self.wake_word_settings or WakeWordSettings() - wake_word_vad: VoiceActivityTimeout | None = None if (wake_word_settings.timeout is not None) and ( wake_word_settings.timeout > 0 @@ -670,6 +680,17 @@ class PipelineRun: if result is None: wake_word_output: dict[str, Any] = {} else: + # Avoid duplicate detections by checking cooldown + last_wake_up = self.hass.data.get(DATA_LAST_WAKE_UP) + if last_wake_up is not None: + sec_since_last_wake_up = time.monotonic() - last_wake_up + if sec_since_last_wake_up < wake_word_settings.cooldown_seconds: + _LOGGER.debug("Duplicate wake word detection occurred") + raise WakeWordDetectionAborted + + # Record last wake up time to block duplicate detections + self.hass.data[DATA_LAST_WAKE_UP] = time.monotonic() + if result.queued_audio: # Add audio that was pending at detection. # @@ -1032,7 +1053,7 @@ class PipelineRun: # Directory to save audio for each pipeline run. # Configured in YAML for assist_pipeline. if debug_recording_dir := self.hass.data[DATA_CONFIG].get( - "debug_recording_dir" + CONF_DEBUG_RECORDING_DIR ): if device_id is None: # // diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 798843ea6e3..fda3e266490 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -15,7 +15,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.util import language as language_util -from .const import DOMAIN +from .const import DEFAULT_PIPELINE_TIMEOUT, DEFAULT_WAKE_WORD_TIMEOUT, DOMAIN from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -30,9 +30,6 @@ from .pipeline import ( async_get_pipeline, ) -DEFAULT_TIMEOUT = 60 * 5 # seconds -DEFAULT_WAKE_WORD_TIMEOUT = 3 # seconds - _LOGGER = logging.getLogger(__name__) @@ -117,7 +114,7 @@ async def websocket_run( ) return - timeout = msg.get("timeout", DEFAULT_TIMEOUT) + timeout = msg.get("timeout", DEFAULT_PIPELINE_TIMEOUT) start_stage = PipelineStage(msg["start_stage"]) end_stage = PipelineStage(msg["end_stage"]) handler_id: int | None = None diff --git a/tests/components/assist_pipeline/snapshots/test_init.ambr b/tests/components/assist_pipeline/snapshots/test_init.ambr index 3f0582f2bfb..e822759d208 100644 --- a/tests/components/assist_pipeline/snapshots/test_init.ambr +++ b/tests/components/assist_pipeline/snapshots/test_init.ambr @@ -285,6 +285,7 @@ 'format': , 'sample_rate': , }), + 'timeout': 0, }), 'type': , }), @@ -396,6 +397,7 @@ 'format': , 'sample_rate': , }), + 'timeout': 0, }), 'type': , }), diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index 7cecf9fed40..b8c668f3fd0 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -373,6 +373,7 @@ 'format': 'wav', 'sample_rate': 16000, }), + 'timeout': 0, }) # --- # name: test_audio_pipeline_with_wake_word_no_timeout.2 @@ -474,6 +475,7 @@ 'format': 'wav', 'sample_rate': 16000, }), + 'timeout': 1, }) # --- # name: test_audio_pipeline_with_wake_word_timeout.2 @@ -655,3 +657,63 @@ # name: test_tts_failed.2 None # --- +# name: test_wake_word_cooldown + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown.3 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown.4 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown.5 + dict({ + 'code': 'wake_word_detection_aborted', + 'message': '', + }) +# --- diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 98ecae628f1..a98858a1bce 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -10,6 +10,10 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components import assist_pipeline, stt +from homeassistant.components.assist_pipeline.const import ( + CONF_DEBUG_RECORDING_DIR, + DOMAIN, +) from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component @@ -395,8 +399,8 @@ async def test_pipeline_save_audio( temp_dir = Path(temp_dir_str) assert await async_setup_component( hass, - "assist_pipeline", - {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, ) pipeline = assist_pipeline.async_get_pipeline(hass) @@ -476,8 +480,8 @@ async def test_pipeline_saved_audio_with_device_id( temp_dir = Path(temp_dir_str) assert await async_setup_component( hass, - "assist_pipeline", - {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, ) def event_callback(event: assist_pipeline.PipelineEvent): @@ -529,8 +533,8 @@ async def test_pipeline_saved_audio_write_error( temp_dir = Path(temp_dir_str) assert await async_setup_component( hass, - "assist_pipeline", - {"assist_pipeline": {"debug_recording_dir": temp_dir_str}}, + DOMAIN, + {DOMAIN: {CONF_DEBUG_RECORDING_DIR: temp_dir_str}}, ) def event_callback(event: assist_pipeline.PipelineEvent): diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index f995a0d3577..28b31e5b19c 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -9,6 +9,8 @@ from homeassistant.components.assist_pipeline.pipeline import Pipeline, Pipeline from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from .conftest import MockWakeWordEntity + from tests.typing import WebSocketGenerator @@ -266,7 +268,7 @@ async def test_audio_pipeline_with_wake_word_no_timeout( events.append(msg["event"]) # "audio" - await client.send_bytes(bytes([1]) + b"wake word") + await client.send_bytes(bytes([handler_id]) + b"wake word") msg = await client.receive_json() assert msg["event"]["type"] == "wake_word-end" @@ -1805,3 +1807,84 @@ async def test_audio_pipeline_with_enhancements( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"events": events} + + +async def test_wake_word_cooldown( + hass: HomeAssistant, + init_components, + mock_wake_word_provider_entity: MockWakeWordEntity, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that duplicate wake word detections are blocked during the cooldown period.""" + client_1 = await hass_ws_client(hass) + client_2 = await hass_ws_client(hass) + + await client_1.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + await client_2.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # result + msg = await client_1.receive_json() + assert msg["success"], msg + + msg = await client_2.receive_json() + assert msg["success"], msg + + # run start + msg = await client_1.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_1 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_2 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + # wake_word + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + # Wake both up at the same time + await client_1.send_bytes(bytes([handler_id_1]) + b"wake word") + await client_2.send_bytes(bytes([handler_id_2]) + b"wake word") + + # Get response events + msg = await client_1.receive_json() + event_type_1 = msg["event"]["type"] + + msg = await client_2.receive_json() + event_type_2 = msg["event"]["type"] + + # One should be a wake up, one should be an error + assert {event_type_1, event_type_2} == {"wake_word-end", "error"} From 579590f7c37a0a6881163ba4d292f7faa04bd1d5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Oct 2023 00:19:21 -0700 Subject: [PATCH 209/968] Fix bug in calendar state where alarms due to alarms not scheduled (#101510) --- homeassistant/components/calendar/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 1622f568a2d..5f6b54824fd 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -540,9 +540,9 @@ class CalendarEntity(Entity): @callback def update(_: datetime.datetime) -> None: - """Run when the active or upcoming event starts or ends.""" + """Update state and reschedule next alarms.""" _LOGGER.debug("Running %s update", self.entity_id) - self._async_write_ha_state() + self.async_write_ha_state() if now < event.start_datetime_local: self._alarm_unsubs.append( From 60fa63a1f072a59e7e8a2cfde39c6d8f470af3ac Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 6 Oct 2023 01:41:01 -0600 Subject: [PATCH 210/968] Adjust WeatherFlow wind sensors to appropriately match native unit and library field (#101418) --- homeassistant/components/weatherflow/sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index dfc8e585f1b..cd648fda360 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -245,7 +245,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( translation_key="wind_gust", icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_SPEED, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, @@ -255,7 +255,7 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( translation_key="wind_lull", icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_SPEED, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, @@ -265,17 +265,17 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.WIND_SPEED, icon="mdi:weather-windy", event_subscriptions=[EVENT_RAPID_WIND, EVENT_OBSERVATION], - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( - key="wind_speed_average", + key="wind_average", translation_key="wind_speed_average", icon="mdi:weather-windy", device_class=SensorDeviceClass.WIND_SPEED, - native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=2, raw_data_conv_fn=lambda raw_data: raw_data.magnitude, From 3478666973349303f48bbe382334e4d3d1670275 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Oct 2023 09:51:08 +0200 Subject: [PATCH 211/968] Use loader.async_suggest_report_issue in deprecation helper (#101393) --- homeassistant/helpers/deprecation.py | 19 ++++++++++++++++--- tests/helpers/test_deprecation.py | 10 ++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 307a297272c..c499dd0b6cd 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -2,12 +2,17 @@ from __future__ import annotations from collections.abc import Callable +from contextlib import suppress import functools import inspect import logging from typing import Any, ParamSpec, TypeVar -from ..helpers.frame import MissingIntegrationFrame, get_integration_frame +from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import async_suggest_report_issue + +from .frame import MissingIntegrationFrame, get_integration_frame _ObjectT = TypeVar("_ObjectT", bound=object) _R = TypeVar("_R") @@ -134,16 +139,24 @@ def _print_deprecation_warning(obj: Any, replacement: str, description: str) -> try: integration_frame = get_integration_frame() if integration_frame.custom_integration: + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) logger.warning( ( "%s was called from %s, this is a deprecated %s. Use %s instead," - " please report this to the maintainer of %s" + " please %s" ), obj.__name__, integration_frame.integration, description, replacement, - integration_frame.integration, + report_issue, ) else: logger.warning( diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py index 9a24cda74dd..1216bd6e293 100644 --- a/tests/helpers/test_deprecation.py +++ b/tests/helpers/test_deprecation.py @@ -3,6 +3,7 @@ from unittest.mock import MagicMock, Mock, patch import pytest +from homeassistant.core import HomeAssistant from homeassistant.helpers.deprecation import ( deprecated_class, deprecated_function, @@ -10,6 +11,8 @@ from homeassistant.helpers.deprecation import ( get_deprecated, ) +from tests.common import MockModule, mock_integration + class MockBaseClassDeprecatedProperty: """Mock base class for deprecated testing.""" @@ -173,6 +176,7 @@ def test_deprecated_function_called_from_built_in_integration( def test_deprecated_function_called_from_custom_integration( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated_function decorator. @@ -180,6 +184,8 @@ def test_deprecated_function_called_from_custom_integration( This tests the behavior when the calling integration is custom. """ + mock_integration(hass, MockModule("hue"), built_in=False) + @deprecated_function("new_function") def mock_deprecated_function(): pass @@ -207,6 +213,6 @@ def test_deprecated_function_called_from_custom_integration( mock_deprecated_function() assert ( "mock_deprecated_function was called from hue, this is a deprecated function. " - "Use new_function instead, please report this to the maintainer of hue" - in caplog.text + "Use new_function instead, please report it to the author of the 'hue' custom " + "integration" in caplog.text ) From 67dfd1a86b66a2455937fa5b903fb0a3c8d0367c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 6 Oct 2023 10:08:01 +0200 Subject: [PATCH 212/968] Update grpcio to 1.59.0 (#101287) --- .github/workflows/wheels.yml | 25 +++++++++++++++++++++++++ homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 2882f855a0d..52455b616ef 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -170,6 +170,16 @@ jobs: split -l $(expr $(expr $(cat requirements_all.txt | wc -l) + 1) / 3) requirements_all.txt requirements_all.txt + - name: Create requirements for cython<3 + run: | + # Some dependencies still require 'cython<3' + # and don't yet use isolated build environments. + # Build these first. + # grpcio: https://github.com/grpc/grpc/issues/33918 + + touch requirements_old-cython.txt + cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt + - name: Adjust build env run: | if [ "${{ matrix.arch }}" = "i386" ]; then @@ -179,6 +189,21 @@ jobs: # Do not pin numpy in wheels building sed -i "/numpy/d" homeassistant/package_constraints.txt + - name: Build wheels (old cython) + uses: home-assistant/wheels@2023.10.1 + with: + abi: ${{ matrix.abi }} + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_old-cython.txt" + pip: "'cython<3'" + - name: Build wheels (part 1) uses: home-assistant/wheels@2023.10.1 with: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 005a6735e03..81b13e6ef9d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -70,9 +70,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.58.0 -grpcio-status==1.58.0 -grpcio-reflection==1.58.0 +grpcio==1.59.0 +grpcio-status==1.59.0 +grpcio-reflection==1.59.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index e27b681f998..7e9218b4cd9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -72,9 +72,9 @@ httplib2>=0.19.0 # gRPC is an implicit dependency that we want to make explicit so we manage # upgrades intentionally. It is a large package to build from source and we # want to ensure we have wheels built. -grpcio==1.58.0 -grpcio-status==1.58.0 -grpcio-reflection==1.58.0 +grpcio==1.59.0 +grpcio-status==1.59.0 +grpcio-reflection==1.59.0 # libcst >=0.4.0 requires a newer Rust than we currently have available, # thus our wheels builds fail. This pins it to the last working version, From d009ff8b014520339b3d3ed4bbd03d491aa6064d Mon Sep 17 00:00:00 2001 From: rappenze Date: Fri, 6 Oct 2023 10:13:30 +0200 Subject: [PATCH 213/968] Add type hints in FibaroController (#101494) --- homeassistant/components/fibaro/__init__.py | 31 +++++++++++---------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 0af6cf02586..364a97b3f6a 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,6 +8,7 @@ from typing import Any from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel +from pyfibaro.fibaro_room import RoomModel from pyfibaro.fibaro_scene import SceneModel from requests.exceptions import HTTPError @@ -86,14 +87,14 @@ class FibaroController: # Whether to import devices from plugins self._import_plugins = config[CONF_IMPORT_PLUGINS] - self._room_map = None # Mapping roomId to room object - self._device_map = None # Mapping deviceId to device object + self._room_map: dict[int, RoomModel] # Mapping roomId to room object + self._device_map: dict[int, DeviceModel] # Mapping deviceId to device object self.fibaro_devices: dict[Platform, list[DeviceModel]] = defaultdict( list ) # List of devices by entity platform # All scenes self._scenes: list[SceneModel] = [] - self._callbacks: dict[Any, Any] = {} # Update value callbacks by deviceId + self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub self.hub_software_version: str @@ -101,7 +102,7 @@ class FibaroController: # Device infos by fibaro device id self._device_infos: dict[int, DeviceInfo] = {} - def connect(self): + def connect(self) -> bool: """Start the communication with the Fibaro controller.""" connected = self._client.connect() @@ -138,15 +139,15 @@ class FibaroController: except Exception as ex: raise FibaroConnectFailed from ex - def enable_state_handler(self): + def enable_state_handler(self) -> None: """Start StateHandler thread for monitoring updates.""" self._client.register_update_handler(self._on_state_change) - def disable_state_handler(self): + def disable_state_handler(self) -> None: """Stop StateHandler thread used for monitoring updates.""" self._client.unregister_update_handler() - def _on_state_change(self, state): + def _on_state_change(self, state: Any) -> None: """Handle change report received from the HomeCenter.""" callback_set = set() for change in state.get("changes", []): @@ -177,12 +178,12 @@ class FibaroController: for callback in self._callbacks[item]: callback() - def register(self, device_id, callback): + def register(self, device_id: int, callback: Any) -> None: """Register device with a callback for updates.""" self._callbacks.setdefault(device_id, []) self._callbacks[device_id].append(callback) - def get_children(self, device_id): + def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" return [ device @@ -190,7 +191,7 @@ class FibaroController: if device.parent_fibaro_id == device_id ] - def get_children2(self, device_id, endpoint_id): + def get_children2(self, device_id: int, endpoint_id: int) -> list[DeviceModel]: """Get a list of child devices for the same endpoint.""" return [ device @@ -199,7 +200,7 @@ class FibaroController: and (not device.has_endpoint_id or device.endpoint_id == endpoint_id) ] - def get_siblings(self, device): + def get_siblings(self, device: DeviceModel) -> list[DeviceModel]: """Get the siblings of a device.""" if device.has_endpoint_id: return self.get_children2( @@ -209,7 +210,7 @@ class FibaroController: return self.get_children(self._device_map[device.fibaro_id].parent_fibaro_id) @staticmethod - def _map_device_to_platform(device: Any) -> Platform | None: + def _map_device_to_platform(device: DeviceModel) -> Platform | None: """Map device to HA device type.""" # Use our lookup table to identify device type platform: Platform | None = None @@ -248,7 +249,7 @@ class FibaroController: if device.parent_fibaro_id <= 1: return - master_entity: Any | None = None + master_entity: DeviceModel | None = None if device.parent_fibaro_id == 1: master_entity = device else: @@ -271,7 +272,7 @@ class FibaroController: via_device=(DOMAIN, self.hub_serial), ) - def get_device_info(self, device: Any) -> DeviceInfo: + def get_device_info(self, device: DeviceModel) -> DeviceInfo: """Get the device info by fibaro device id.""" if device.fibaro_id in self._device_infos: return self._device_infos[device.fibaro_id] @@ -289,7 +290,7 @@ class FibaroController: """Return list of scenes.""" return self._scenes - def _read_devices(self): + def _read_devices(self) -> None: """Read and process the device list.""" devices = self._client.read_devices() self._device_map = {} From fa90b0f41e6ba2fa33693522b69b8d7278f4ece4 Mon Sep 17 00:00:00 2001 From: Ernst Klamer Date: Fri, 6 Oct 2023 10:22:51 +0200 Subject: [PATCH 214/968] Add raw sensor to BTHome (#101412) --- homeassistant/components/bthome/manifest.json | 2 +- homeassistant/components/bthome/sensor.py | 4 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bthome/test_sensor.py | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 01db154306f..a7729cc256e 100644 --- a/homeassistant/components/bthome/manifest.json +++ b/homeassistant/components/bthome/manifest.json @@ -20,5 +20,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/bthome", "iot_class": "local_push", - "requirements": ["bthome-ble==3.1.1"] + "requirements": ["bthome-ble==3.2.0"] } diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 06f205246c8..10ba292d20c 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -228,6 +228,10 @@ SENSOR_DESCRIPTIONS = { native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, ), + # Raw (-) + (BTHomeExtendedSensorDeviceClass.RAW, None): SensorEntityDescription( + key=str(BTHomeExtendedSensorDeviceClass.RAW), + ), # Rotation (°) (BTHomeSensorDeviceClass.ROTATION, Units.DEGREE): SensorEntityDescription( key=f"{BTHomeSensorDeviceClass.ROTATION}_{Units.DEGREE}", diff --git a/requirements_all.txt b/requirements_all.txt index 3bd419d787a..c4a0d14dae7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -581,7 +581,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.1.1 +bthome-ble==3.2.0 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4f7123377f..30dcc0ba712 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -488,7 +488,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.1.1 +bthome-ble==3.2.0 # homeassistant.components.buienradar buienradar==1.0.5 diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 831f7811972..c1f8e26ccb2 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -957,6 +957,21 @@ async def test_v1_sensors( }, ], ), + ( + "A4:C1:38:8D:18:B2", + make_bthome_v2_adv( + "A4:C1:38:8D:18:B2", + b"\x44\x54\x0C\x48\x65\x6C\x6C\x6F\x20\x57\x6F\x72\x6C\x64\x21", + ), + None, + [ + { + "sensor_entity": "sensor.test_device_18b2_raw", + "friendly_name": "Test Device 18B2 Raw", + "expected_state": "48656c6c6f20576f726c6421", + }, + ], + ), ( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( From b97ec2cfce484d56010e61c4b7f03b39f193e654 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Fri, 6 Oct 2023 11:26:18 +0300 Subject: [PATCH 215/968] Add support for Aranet2 devices (#101495) --- homeassistant/components/aranet/manifest.json | 2 +- homeassistant/components/aranet/sensor.py | 30 ++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aranet/__init__.py | 8 ++ tests/components/aranet/test_sensor.py | 77 ++++++++++++++----- 6 files changed, 85 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/aranet/manifest.json b/homeassistant/components/aranet/manifest.json index 589cdc56e85..0d22a0d1859 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.1.3"] + "requirements": ["aranet4==2.2.2"] } diff --git a/homeassistant/components/aranet/sensor.py b/homeassistant/components/aranet/sensor.py index b47af54a51f..ad11b4bdbdc 100644 --- a/homeassistant/components/aranet/sensor.py +++ b/homeassistant/components/aranet/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any from aranet4.client import Aranet4Advertisement from bleak.backends.device import BLEDevice @@ -32,6 +33,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -121,22 +123,22 @@ def sensor_update_to_bluetooth_data_update( adv: Aranet4Advertisement, ) -> PassiveBluetoothDataUpdate: """Convert a sensor update to a Bluetooth data update.""" + data: dict[PassiveBluetoothEntityKey, Any] = {} + names: dict[PassiveBluetoothEntityKey, str | None] = {} + descs: dict[PassiveBluetoothEntityKey, EntityDescription] = {} + for key, desc in SENSOR_DESCRIPTIONS.items(): + tag = _device_key_to_bluetooth_entity_key(adv.device, key) + val = getattr(adv.readings, key) + if val == -1: + continue + data[tag] = val + names[tag] = desc.name + descs[tag] = desc return PassiveBluetoothDataUpdate( devices={adv.device.address: _sensor_device_info_to_hass(adv)}, - entity_descriptions={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc - for key, desc in SENSOR_DESCRIPTIONS.items() - }, - entity_data={ - _device_key_to_bluetooth_entity_key(adv.device, key): getattr( - adv.readings, key, None - ) - for key in SENSOR_DESCRIPTIONS - }, - entity_names={ - _device_key_to_bluetooth_entity_key(adv.device, key): desc.name - for key, desc in SENSOR_DESCRIPTIONS.items() - }, + entity_descriptions=descs, + entity_data=data, + entity_names=names, ) diff --git a/requirements_all.txt b/requirements_all.txt index c4a0d14dae7..37d5b012372 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -435,7 +435,7 @@ aprslib==0.7.0 aqualogic==2.6 # homeassistant.components.aranet -aranet4==2.1.3 +aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30dcc0ba712..692fd0b161d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -398,7 +398,7 @@ apprise==1.5.0 aprslib==0.7.0 # homeassistant.components.aranet -aranet4==2.1.3 +aranet4==2.2.2 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 diff --git a/tests/components/aranet/__init__.py b/tests/components/aranet/__init__.py index c85748abea4..b559743067d 100644 --- a/tests/components/aranet/__init__.py +++ b/tests/components/aranet/__init__.py @@ -57,3 +57,11 @@ VALID_DATA_SERVICE_INFO = fake_service_info( 1794: b'\x21\x00\x02\x01\x00\x00\x00\x01\x8a\x02\xa5\x01\xb1&"Y\x01,\x01\xe8\x00\x88' }, ) + +VALID_ARANET2_DATA_SERVICE_INFO = fake_service_info( + "Aranet2 12345", + "0000fce0-0000-1000-8000-00805f9b34fb", + { + 1794: b"\x01!\x04\x04\x01\x00\x00\x00\x00\x00\xf0\x01\x00\x00\x0c\x02\x00O\x00<\x00\x01\x00\x80" + }, +) diff --git a/tests/components/aranet/test_sensor.py b/tests/components/aranet/test_sensor.py index 7d531bf6111..0b2b4771069 100644 --- a/tests/components/aranet/test_sensor.py +++ b/tests/components/aranet/test_sensor.py @@ -4,16 +4,70 @@ from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant -from . import DISABLED_INTEGRATIONS_SERVICE_INFO, VALID_DATA_SERVICE_INFO +from . import ( + DISABLED_INTEGRATIONS_SERVICE_INFO, + VALID_ARANET2_DATA_SERVICE_INFO, + VALID_DATA_SERVICE_INFO, +) from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors( +async def test_sensors_aranet2( hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: - """Test setting up creates the sensors.""" + """Test setting up creates the sensors for Aranet2 device.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 0 + inject_bluetooth_service_info(hass, VALID_ARANET2_DATA_SERVICE_INFO) + await hass.async_block_till_done() + assert len(hass.states.async_all("sensor")) == 4 + + batt_sensor = hass.states.get("sensor.aranet2_12345_battery") + batt_sensor_attrs = batt_sensor.attributes + assert batt_sensor.state == "79" + assert batt_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Battery" + assert batt_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert batt_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humid_sensor = hass.states.get("sensor.aranet2_12345_humidity") + humid_sensor_attrs = humid_sensor.attributes + assert humid_sensor.state == "52.4" + assert humid_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Humidity" + assert humid_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humid_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + temp_sensor = hass.states.get("sensor.aranet2_12345_temperature") + temp_sensor_attrs = temp_sensor.attributes + assert temp_sensor.state == "24.8" + assert temp_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Temperature" + assert temp_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temp_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + interval_sensor = hass.states.get("sensor.aranet2_12345_update_interval") + interval_sensor_attrs = interval_sensor.attributes + assert interval_sensor.state == "60" + assert interval_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet2 12345 Update Interval" + assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s" + assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sensors_aranet4( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test setting up creates the sensors for Aranet4 device.""" entry = MockConfigEntry( domain=DOMAIN, unique_id="aa:bb:cc:dd:ee:ff", @@ -90,22 +144,7 @@ async def test_smart_home_integration_disabled( assert len(hass.states.async_all("sensor")) == 0 inject_bluetooth_service_info(hass, DISABLED_INTEGRATIONS_SERVICE_INFO) await hass.async_block_till_done() - assert len(hass.states.async_all("sensor")) == 6 - - batt_sensor = hass.states.get("sensor.aranet4_12345_battery") - assert batt_sensor.state == "unavailable" - - co2_sensor = hass.states.get("sensor.aranet4_12345_carbon_dioxide") - assert co2_sensor.state == "unavailable" - - humid_sensor = hass.states.get("sensor.aranet4_12345_humidity") - assert humid_sensor.state == "unavailable" - - temp_sensor = hass.states.get("sensor.aranet4_12345_temperature") - assert temp_sensor.state == "unavailable" - - press_sensor = hass.states.get("sensor.aranet4_12345_pressure") - assert press_sensor.state == "unavailable" + assert len(hass.states.async_all("sensor")) == 0 assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 775751ece5902aebffa89c06baacc471831523a6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Oct 2023 10:27:45 +0200 Subject: [PATCH 216/968] Add WS command sensor/numeric_device_classes (#101257) --- .../components/sensor/websocket_api.py | 24 ++++++++++++++++- tests/components/sensor/test_websocket_api.py | 27 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/websocket_api.py b/homeassistant/components/sensor/websocket_api.py index 2457bfcabe3..a98c4b25392 100644 --- a/homeassistant/components/sensor/websocket_api.py +++ b/homeassistant/components/sensor/websocket_api.py @@ -8,13 +8,19 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from .const import DEVICE_CLASS_UNITS, UNIT_CONVERTERS +from .const import ( + DEVICE_CLASS_UNITS, + NON_NUMERIC_DEVICE_CLASSES, + UNIT_CONVERTERS, + SensorDeviceClass, +) @callback def async_setup(hass: HomeAssistant) -> None: """Set up the sensor websocket API.""" websocket_api.async_register_command(hass, ws_device_class_units) + websocket_api.async_register_command(hass, ws_numeric_device_classes) @callback @@ -36,3 +42,19 @@ def ws_device_class_units( key=lambda s: str.casefold(str(s)), ) connection.send_result(msg["id"], {"units": convertible_units}) + + +@callback +@websocket_api.websocket_command( + { + vol.Required("type"): "sensor/numeric_device_classes", + } +) +def ws_numeric_device_classes( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Return numeric sensor device classes.""" + numeric_device_classes = set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES + connection.send_result( + msg["id"], {"numeric_device_classes": list(numeric_device_classes)} + ) diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index 17b8a2ab5cb..bd0a68598e1 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -1,5 +1,11 @@ """Test the sensor websocket API.""" -from homeassistant.components.sensor.const import DOMAIN +from pytest_unordered import unordered + +from homeassistant.components.sensor.const import ( + DOMAIN, + NON_NUMERIC_DEVICE_CLASSES, + SensorDeviceClass, +) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -59,3 +65,22 @@ async def test_device_class_units( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"units": []} + + +async def test_numeric_device_classes( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test we can get numeric device classes.""" + numeric_device_classes = set(SensorDeviceClass) - NON_NUMERIC_DEVICE_CLASSES + + assert await async_setup_component(hass, DOMAIN, {}) + + client = await hass_ws_client(hass) + + # Device class with units which sensor allows customizing & converting + await client.send_json_auto_id({"type": "sensor/numeric_device_classes"}) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "numeric_device_classes": unordered(list(numeric_device_classes)) + } From 20188181f7c6d669026de4054358b379f6d59c35 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Oct 2023 11:51:08 +0200 Subject: [PATCH 217/968] Fix spelling in sensor test (#101520) --- tests/helpers/test_entity.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 68a09310540..ff9ad99435f 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1551,7 +1551,7 @@ async def test_suggest_report_issue_custom_component( mock_integration( hass, MockModule( - domain="test", partial_manifest={"issue_tracker": "httpts://some_url"} + domain="test", partial_manifest={"issue_tracker": "https://some_url"} ), built_in=False, ) @@ -1559,4 +1559,4 @@ async def test_suggest_report_issue_custom_component( await platform.async_add_entities([mock_entity]) suggestion = mock_entity._suggest_report_issue() - assert suggestion == "create a bug report at httpts://some_url" + assert suggestion == "create a bug report at https://some_url" From 00d07676288ec552f40cbd3bb7a2675df1b02aa2 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 6 Oct 2023 19:59:27 +1000 Subject: [PATCH 218/968] Add missing return type in Advantage Air (#101377) * Add None return type * Change mypi type * Fix mypy issue --- homeassistant/components/advantage_air/climate.py | 2 +- homeassistant/components/advantage_air/entity.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/advantage_air/climate.py b/homeassistant/components/advantage_air/climate.py index cda123f62ee..a4e0a1033ba 100644 --- a/homeassistant/components/advantage_air/climate.py +++ b/homeassistant/components/advantage_air/climate.py @@ -127,7 +127,7 @@ class AdvantageAirAC(AdvantageAirAcEntity, ClimateEntity): """Return the current target temperature.""" # If the system is in MyZone mode, and a zone is set, return that temperature instead. if ( - self._ac["myZone"] > 0 + self._myzone and not self._ac.get(ADVANTAGE_AIR_MYAUTO_ENABLED) and not self._ac.get(ADVANTAGE_AIR_MYTEMP_ENABLED) ): diff --git a/homeassistant/components/advantage_air/entity.py b/homeassistant/components/advantage_air/entity.py index b300a677793..691db99769b 100644 --- a/homeassistant/components/advantage_air/entity.py +++ b/homeassistant/components/advantage_air/entity.py @@ -63,7 +63,7 @@ class AdvantageAirAcEntity(AdvantageAirEntity): return self.coordinator.data["aircons"][self.ac_key]["info"] @property - def _myzone(self) -> dict[str, Any]: + def _myzone(self) -> dict[str, Any] | None: return self.coordinator.data["aircons"][self.ac_key]["zones"].get( f"z{self._ac['myZone']:02}" ) From 7c8c063149fc7f3ab07d99922596d6ee756ecb5c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 12:02:53 +0200 Subject: [PATCH 219/968] Use config flow in color extractor tests (#101524) --- tests/components/color_extractor/conftest.py | 21 +++++++++ .../fixtures/color_extractor_file.txt | 0 .../fixtures/color_extractor_url.txt | 0 tests/components/color_extractor/test_init.py | 17 +++++++ .../color_extractor/test_service.py | 46 ++++++++----------- 5 files changed, 57 insertions(+), 27 deletions(-) create mode 100644 tests/components/color_extractor/conftest.py rename tests/{ => components/color_extractor}/fixtures/color_extractor_file.txt (100%) rename tests/{ => components/color_extractor}/fixtures/color_extractor_url.txt (100%) create mode 100644 tests/components/color_extractor/test_init.py diff --git a/tests/components/color_extractor/conftest.py b/tests/components/color_extractor/conftest.py new file mode 100644 index 00000000000..299c8019f94 --- /dev/null +++ b/tests/components/color_extractor/conftest.py @@ -0,0 +1,21 @@ +"""Common fixtures for the Color extractor tests.""" +import pytest + +from homeassistant.components.color_extractor.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +async def config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry(domain=DOMAIN, data={}) + + +@pytest.fixture +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Add config entry for color extractor.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/fixtures/color_extractor_file.txt b/tests/components/color_extractor/fixtures/color_extractor_file.txt similarity index 100% rename from tests/fixtures/color_extractor_file.txt rename to tests/components/color_extractor/fixtures/color_extractor_file.txt diff --git a/tests/fixtures/color_extractor_url.txt b/tests/components/color_extractor/fixtures/color_extractor_url.txt similarity index 100% rename from tests/fixtures/color_extractor_url.txt rename to tests/components/color_extractor/fixtures/color_extractor_url.txt diff --git a/tests/components/color_extractor/test_init.py b/tests/components/color_extractor/test_init.py new file mode 100644 index 00000000000..797eaf291fe --- /dev/null +++ b/tests/components/color_extractor/test_init.py @@ -0,0 +1,17 @@ +"""Test Color extractor component setup process.""" +from homeassistant.components.color_extractor import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1 diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index ae3e799e9d2..361127c332b 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -1,6 +1,7 @@ """Tests for color_extractor component service calls.""" import base64 import io +from typing import Any from unittest.mock import Mock, mock_open, patch import aiohttp @@ -92,15 +93,8 @@ async def setup_light(hass: HomeAssistant): assert state.state == STATE_OFF -async def test_missing_url_and_path(hass: HomeAssistant) -> None: +async def test_missing_url_and_path(hass: HomeAssistant, setup_integration) -> None: """Test that nothing happens when url and path are missing.""" - # Load our color_extractor component - await async_setup_component( - hass, - DOMAIN, - {}, - ) - await hass.async_block_till_done() # Validate pre service call state = hass.states.get(LIGHT_ENTITY) @@ -124,15 +118,7 @@ async def test_missing_url_and_path(hass: HomeAssistant) -> None: assert state.state == STATE_OFF -async def _async_load_color_extractor_url(hass, service_data): - # Load our color_extractor component - await async_setup_component( - hass, - DOMAIN, - {}, - ) - await hass.async_block_till_done() - +async def _async_execute_service(hass: HomeAssistant, service_data: dict[str, Any]): # Validate pre service call state = hass.states.get(LIGHT_ENTITY) assert state @@ -145,7 +131,7 @@ async def _async_load_color_extractor_url(hass, service_data): async def test_url_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration ) -> None: """Test that a successful image GET translate to light RGB.""" service_data = { @@ -158,13 +144,15 @@ async def test_url_success( # Mock the HTTP Response with a base64 encoded 1x1 pixel aioclient_mock.get( url=service_data[ATTR_URL], - content=base64.b64decode(load_fixture("color_extractor_url.txt")), + content=base64.b64decode( + load_fixture("color_extractor/color_extractor_url.txt") + ), ) # Allow access to this URL using the proper mechanism hass.config.allowlist_external_urls.add("http://example.com/images/") - await _async_load_color_extractor_url(hass, service_data) + await _async_execute_service(hass, service_data) state = hass.states.get(LIGHT_ENTITY) assert state @@ -180,7 +168,7 @@ async def test_url_success( async def test_url_not_allowed( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration ) -> None: """Test that a not allowed external URL fails to turn light on.""" service_data = { @@ -188,7 +176,7 @@ async def test_url_not_allowed( ATTR_ENTITY_ID: LIGHT_ENTITY, } - await _async_load_color_extractor_url(hass, service_data) + await _async_execute_service(hass, service_data) # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -197,7 +185,7 @@ async def test_url_not_allowed( async def test_url_exception( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration ) -> None: """Test that a HTTPError fails to turn light on.""" service_data = { @@ -211,7 +199,7 @@ async def test_url_exception( # Mock the HTTP Response with an HTTPError aioclient_mock.get(url=service_data[ATTR_URL], exc=aiohttp.ClientError) - await _async_load_color_extractor_url(hass, service_data) + await _async_execute_service(hass, service_data) # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -220,7 +208,7 @@ async def test_url_exception( async def test_url_error( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, setup_integration ) -> None: """Test that a HTTP Error (non 200) doesn't turn light on.""" service_data = { @@ -234,7 +222,7 @@ async def test_url_error( # Mock the HTTP Response with a 400 Bad Request error aioclient_mock.get(url=service_data[ATTR_URL], status=400) - await _async_load_color_extractor_url(hass, service_data) + await _async_execute_service(hass, service_data) # Light has not been modified due to failure state = hass.states.get(LIGHT_ENTITY) @@ -244,7 +232,11 @@ async def test_url_error( @patch( "builtins.open", - mock_open(read_data=base64.b64decode(load_fixture("color_extractor_file.txt"))), + mock_open( + read_data=base64.b64decode( + load_fixture("color_extractor/color_extractor_file.txt") + ) + ), create=True, ) def _get_file_mock(file_path): From c0904c905dcee71fb3de2a457f8d6ff426b241f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Oct 2023 05:14:48 -0500 Subject: [PATCH 220/968] Avoid updating hassio addon data when there are no entities consuming it (#101382) --- homeassistant/components/hassio/__init__.py | 130 +++++++++++++------ homeassistant/components/hassio/const.py | 15 +++ homeassistant/components/hassio/entity.py | 11 ++ tests/components/hassio/test_sensor.py | 137 +++++++++++++++++--- 4 files changed, 236 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 75b2535bd44..a471bf820c8 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import defaultdict from contextlib import suppress from datetime import datetime, timedelta import logging @@ -29,6 +30,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import ( + CALLBACK_TYPE, DOMAIN as HASS_DOMAIN, HassJob, HomeAssistant, @@ -55,6 +57,9 @@ from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # no from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view from .const import ( + ADDON_UPDATE_CHANGELOG, + ADDON_UPDATE_INFO, + ADDON_UPDATE_STATS, ATTR_ADDON, ATTR_ADDONS, ATTR_AUTO_UPDATE, @@ -800,11 +805,16 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.entry_id = config_entry.entry_id self.dev_reg = dev_reg self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None + self._enabled_updates_by_addon: defaultdict[ + str, dict[str, set[str]] + ] = defaultdict(lambda: defaultdict(set)) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" + is_first_update = not self.data + try: - await self.force_data_refresh() + await self.force_data_refresh(is_first_update) except HassioAPIError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err @@ -848,7 +858,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): new_data[DATA_KEY_HOST] = get_host_info(self.hass) or {} # If this is the initial refresh, register all addons and return the dict - if not self.data: + if is_first_update: async_register_addons_in_dev_reg( self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() ) @@ -898,47 +908,75 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.hass.data[DATA_SUPERVISOR_INFO] = await self.hassio.get_supervisor_info() await self.async_refresh() - async def force_data_refresh(self) -> None: + async def force_data_refresh(self, first_update: bool) -> None: """Force update of the addon info.""" + data = self.hass.data + hassio = self.hassio ( - self.hass.data[DATA_INFO], - self.hass.data[DATA_CORE_INFO], - self.hass.data[DATA_CORE_STATS], - self.hass.data[DATA_SUPERVISOR_INFO], - self.hass.data[DATA_SUPERVISOR_STATS], - self.hass.data[DATA_OS_INFO], + data[DATA_INFO], + data[DATA_CORE_INFO], + data[DATA_CORE_STATS], + data[DATA_SUPERVISOR_INFO], + data[DATA_SUPERVISOR_STATS], + data[DATA_OS_INFO], ) = await asyncio.gather( - self.hassio.get_info(), - self.hassio.get_core_info(), - self.hassio.get_core_stats(), - self.hassio.get_supervisor_info(), - self.hassio.get_supervisor_stats(), - self.hassio.get_os_info(), + hassio.get_info(), + hassio.get_core_info(), + hassio.get_core_stats(), + hassio.get_supervisor_info(), + hassio.get_supervisor_stats(), + hassio.get_os_info(), ) - all_addons = self.hass.data[DATA_SUPERVISOR_INFO].get("addons", []) - started_addons = [ - addon for addon in all_addons if addon[ATTR_STATE] == ATTR_STARTED - ] - stats_data = await asyncio.gather( - *[self._update_addon_stats(addon[ATTR_SLUG]) for addon in started_addons] - ) - self.hass.data[DATA_ADDONS_STATS] = dict(stats_data) - self.hass.data[DATA_ADDONS_CHANGELOGS] = dict( - await asyncio.gather( - *[ - self._update_addon_changelog(addon[ATTR_SLUG]) - for addon in all_addons - ] + _addon_data = data[DATA_SUPERVISOR_INFO].get("addons", []) + all_addons: list[str] = [] + started_addons: list[str] = [] + for addon in _addon_data: + slug = addon[ATTR_SLUG] + all_addons.append(slug) + if addon[ATTR_STATE] == ATTR_STARTED: + started_addons.append(slug) + # + # Update add-on info if its the first update or + # there is at least one entity that needs the data. + # + # When entities are added they call async_enable_addon_updates + # to enable updates for the endpoints they need via + # async_added_to_hass. This ensures that we only update + # the data for the endpoints that are needed to avoid unnecessary + # API calls since otherwise we would fetch stats for all add-ons + # and throw them away. + # + enabled_updates_by_addon = self._enabled_updates_by_addon + for data_key, update_func, enabled_key, wanted_addons in ( + ( + DATA_ADDONS_STATS, + self._update_addon_stats, + ADDON_UPDATE_STATS, + started_addons, + ), + ( + DATA_ADDONS_CHANGELOGS, + self._update_addon_changelog, + ADDON_UPDATE_CHANGELOG, + all_addons, + ), + (DATA_ADDONS_INFO, self._update_addon_info, ADDON_UPDATE_INFO, all_addons), + ): + data.setdefault(data_key, {}).update( + dict( + await asyncio.gather( + *[ + update_func(slug) + for slug in wanted_addons + if first_update + or enabled_key in enabled_updates_by_addon[slug] + ] + ) + ) ) - ) - self.hass.data[DATA_ADDONS_INFO] = dict( - await asyncio.gather( - *[self._update_addon_info(addon[ATTR_SLUG]) for addon in all_addons] - ) - ) - async def _update_addon_stats(self, slug): + async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Update single addon stats.""" try: stats = await self.hassio.get_addon_stats(slug) @@ -947,7 +985,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) return (slug, None) - async def _update_addon_changelog(self, slug): + async def _update_addon_changelog(self, slug: str) -> tuple[str, str | None]: """Return the changelog for an add-on.""" try: changelog = await self.hassio.get_addon_changelog(slug) @@ -956,7 +994,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch changelog for %s: %s", slug, err) return (slug, None) - async def _update_addon_info(self, slug): + async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: """Return the info for an add-on.""" try: info = await self.hassio.get_addon_info(slug) @@ -965,6 +1003,22 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.warning("Could not fetch info for %s: %s", slug, err) return (slug, None) + @callback + def async_enable_addon_updates( + self, slug: str, entity_id: str, types: set[str] + ) -> CALLBACK_TYPE: + """Enable updates for an add-on.""" + enabled_updates = self._enabled_updates_by_addon[slug] + for key in types: + enabled_updates[key].add(entity_id) + + @callback + def _remove(): + for key in types: + enabled_updates[key].remove(entity_id) + + return _remove + async def _async_refresh( self, log_failures: bool = True, diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 5712f5d1bea..3d2ff7b0cff 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -82,6 +82,21 @@ PLACEHOLDER_KEY_COMPONENTS = "components" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" +ADDON_UPDATE_STATS = "stats" +ADDON_UPDATE_CHANGELOG = "changelog" +ADDON_UPDATE_INFO = "info" + +# This is a mapping of which endpoint the key in the addon data +# is obtained from so we know which endpoint to update when the +# coordinator polls for updates. +KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { + ATTR_VERSION_LATEST: {ADDON_UPDATE_INFO, ADDON_UPDATE_CHANGELOG}, + ATTR_MEMORY_PERCENT: {ADDON_UPDATE_STATS}, + ATTR_CPU_PERCENT: {ADDON_UPDATE_STATS}, + ATTR_VERSION: {ADDON_UPDATE_INFO}, + ATTR_STATE: {ADDON_UPDATE_INFO}, +} + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 6530aba3ea1..5421a3ea953 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -15,6 +15,7 @@ from .const import ( DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, + KEY_TO_UPDATE_TYPES, ) @@ -46,6 +47,16 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) ) + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_addon_updates( + self._addon_slug, self.entity_id, update_types + ) + ) + class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Hass.io OS.""" diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index d33c6697321..817bf871fef 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -1,23 +1,63 @@ """The tests for the hassio sensors.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN +from homeassistant.components.hassio import ( + DOMAIN, + HASSIO_UPDATE_INTERVAL, + HassioAPIError, +) 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 tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) -def mock_all(aioclient_mock, request): +def mock_all(aioclient_mock: AiohttpClientMocker, request): """Mock all setup requests.""" + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_mock(aioclient_mock) + + +def _install_test_addon_stats_mock(aioclient_mock: AiohttpClientMocker): + """Install mock to provide valid stats for the test addon.""" + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + + +def _install_test_addon_stats_failure_mock(aioclient_mock: AiohttpClientMocker): + """Install mocks to raise an exception when fetching stats for the test addon.""" + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + exc=HassioAPIError, + ) + + +def _install_default_mocks(aioclient_mock: AiohttpClientMocker): + """Install default mocks.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) @@ -79,6 +119,7 @@ def mock_all(aioclient_mock, request): "version_latest": "2.0.1", "repository": "core", "url": "https://github.com/home-assistant/addons/test", + "icon": False, }, { "name": "test2", @@ -90,27 +131,12 @@ def mock_all(aioclient_mock, request): "version_latest": "3.2.0", "repository": "core", "url": "https://github.com", + "icon": False, }, ], }, }, ) - aioclient_mock.get( - "http://127.0.0.1/addons/test/stats", - json={ - "result": "ok", - "data": { - "cpu_percent": 0.99, - "memory_usage": 182611968, - "memory_limit": 3977146368, - "memory_percent": 4.59, - "network_rx": 362570232, - "network_tx": 82374138, - "blk_read": 46010945536, - "blk_write": 15051526144, - }, - }, - ) aioclient_mock.get( "http://127.0.0.1/core/stats", json={ @@ -196,6 +222,7 @@ async def test_sensor( expected, aioclient_mock: AiohttpClientMocker, entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """Test hassio OS and addons sensor.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) @@ -221,3 +248,75 @@ async def test_sensor( # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected + + +@pytest.mark.parametrize( + ("entity_id", "expected"), + [ + ("sensor.test_cpu_percent", "0.99"), + ("sensor.test_memory_percent", "4.59"), + ], +) +async def test_stats_addon_sensor( + hass: HomeAssistant, + entity_id, + expected, + aioclient_mock: AiohttpClientMocker, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test stats addons sensor.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component( + hass, + "hassio", + {"http": {"server_port": 9999, "server_host": "127.0.0.1"}, "hassio": {}}, + ) + assert result + await hass.async_block_till_done() + + # Verify that the entity is disabled by default. + assert hass.states.get(entity_id) is None + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_failure_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + assert "Could not fetch stats" not in caplog.text + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + # Enable the entity. + entity_registry.async_update_entity(entity_id, disabled_by=None) + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() + + # Verify that the entity have the expected state. + state = hass.states.get(entity_id) + assert state.state == expected + + aioclient_mock.clear_requests() + _install_default_mocks(aioclient_mock) + _install_test_addon_stats_failure_mock(aioclient_mock) + + async_fire_time_changed( + hass, dt_util.utcnow() + HASSIO_UPDATE_INTERVAL + timedelta(seconds=1) + ) + await hass.async_block_till_done() + + assert "Could not fetch stats" in caplog.text From 4553de5cbfbfd2201d2f0e4e0779b4d870583a03 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 6 Oct 2023 12:15:01 +0200 Subject: [PATCH 221/968] Use string conversion over isinstance in mqtt message handling if possible (#101364) --- homeassistant/components/mqtt/alarm_control_panel.py | 2 +- homeassistant/components/mqtt/device_tracker.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index dddf8986ca0..a960367ad11 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -177,7 +177,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): if (code := self._config.get(CONF_CODE)) is None: self._attr_code_format = None - elif code == REMOTE_CODE or (isinstance(code, str) and code.isdigit()): + elif code == REMOTE_CODE or str(code).isdigit(): self._attr_code_format = alarm.CodeFormat.NUMBER else: self._attr_code_format = alarm.CodeFormat.TEXT diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 2557a2afb5d..1293121e0a8 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 import functools +from typing import TYPE_CHECKING import voluptuous as vol @@ -137,7 +138,8 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): elif payload == self._config[CONF_PAYLOAD_RESET]: self._location_name = None else: - assert isinstance(msg.payload, str) + if TYPE_CHECKING: + assert isinstance(msg.payload, str) self._location_name = msg.payload state_topic: str | None = self._config.get(CONF_STATE_TOPIC) From ca7355b2f3c92ebe42a6a67d02a0604b3945074f Mon Sep 17 00:00:00 2001 From: YuriiMaiboroda <47284191+YuriiMaiboroda@users.noreply.github.com> Date: Fri, 6 Oct 2023 13:26:20 +0300 Subject: [PATCH 222/968] Using the MarkdownV2 parser with the Telegram bot (#101139) --- homeassistant/components/telegram_bot/__init__.py | 7 ++++++- homeassistant/components/telegram_bot/services.yaml | 12 ++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 3d56cc7ed33..76677c3813e 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -120,6 +120,7 @@ EVENT_TELEGRAM_SENT = "telegram_sent" PARSER_HTML = "html" PARSER_MD = "markdown" +PARSER_MD2 = "markdownv2" DEFAULT_TRUSTED_NETWORKS = [ip_network("149.154.160.0/20"), ip_network("91.108.4.0/22")] @@ -474,7 +475,11 @@ class TelegramNotificationService: self.allowed_chat_ids = allowed_chat_ids self._default_user = self.allowed_chat_ids[0] self._last_message_id = {user: None for user in self.allowed_chat_ids} - self._parsers = {PARSER_HTML: ParseMode.HTML, PARSER_MD: ParseMode.MARKDOWN} + self._parsers = { + PARSER_HTML: ParseMode.HTML, + PARSER_MD: ParseMode.MARKDOWN, + PARSER_MD2: ParseMode.MARKDOWN_V2, + } self._parse_mode = self._parsers.get(parser) self.bot = bot self.hass = hass diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index cdb50d55943..94d1eee1b55 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -21,7 +21,7 @@ send_message: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -90,7 +90,7 @@ send_photo: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -217,7 +217,7 @@ send_animation: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -280,7 +280,7 @@ send_video: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -407,7 +407,7 @@ send_document: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_notification: selector: boolean: @@ -543,7 +543,7 @@ edit_message: options: - "html" - "markdown" - - "markdown2" + - "markdownv2" disable_web_page_preview: selector: boolean: From 97d17637eaa934421efd55d9f8c6698b6a6bbd81 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 13:18:44 +0200 Subject: [PATCH 223/968] Only import color extractor when domain is in config (#101522) --- .../components/color_extractor/__init__.py | 14 +++++++++----- tests/components/color_extractor/test_init.py | 2 +- tests/components/color_extractor/test_service.py | 10 ++-------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index af460f819cd..2cc3e206958 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -24,7 +24,10 @@ from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +CONFIG_SCHEMA = vol.Schema( + {vol.Optional(DOMAIN): {}}, + extra=vol.ALLOW_EXTRA, +) # Extend the existing light.turn_on service schema SERVICE_SCHEMA = vol.All( @@ -62,11 +65,12 @@ def _get_color(file_handler) -> tuple: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Color extractor component.""" - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data={} + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) ) - ) return True diff --git a/tests/components/color_extractor/test_init.py b/tests/components/color_extractor/test_init.py index 797eaf291fe..b4874b575e8 100644 --- a/tests/components/color_extractor/test_init.py +++ b/tests/components/color_extractor/test_init.py @@ -8,7 +8,7 @@ from homeassistant.setup import async_setup_component async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" - assert await async_setup_component(hass, DOMAIN, {}) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/color_extractor/test_service.py b/tests/components/color_extractor/test_service.py index 361127c332b..647d945f158 100644 --- a/tests/components/color_extractor/test_service.py +++ b/tests/components/color_extractor/test_service.py @@ -254,7 +254,7 @@ def _get_file_mock(file_path): @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file(hass: HomeAssistant) -> None: +async def test_file(hass: HomeAssistant, setup_integration) -> None: """Test that the file only service reads a file and translates to light RGB.""" service_data = { ATTR_PATH: "/opt/image.png", @@ -266,9 +266,6 @@ async def test_file(hass: HomeAssistant) -> None: # Add our /opt/ path to the allowed list of paths hass.config.allowlist_external_dirs.add("/opt/") - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - # Verify pre service check state = hass.states.get(LIGHT_ENTITY) assert state @@ -295,7 +292,7 @@ async def test_file(hass: HomeAssistant) -> None: @patch("os.path.isfile", Mock(return_value=True)) @patch("os.access", Mock(return_value=True)) -async def test_file_denied_dir(hass: HomeAssistant) -> None: +async def test_file_denied_dir(hass: HomeAssistant, setup_integration) -> None: """Test that the file only service fails to read an image in a dir not explicitly allowed.""" service_data = { ATTR_PATH: "/path/to/a/dir/not/allowed/image.png", @@ -304,9 +301,6 @@ async def test_file_denied_dir(hass: HomeAssistant) -> None: ATTR_BRIGHTNESS_PCT: 100, } - await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - # Verify pre service check state = hass.states.get(LIGHT_ENTITY) assert state From 425d9614893d93bf3e19a59111964a702d1d1b54 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 13:19:39 +0200 Subject: [PATCH 224/968] Delete existing Withings cloudhook (#101527) --- homeassistant/components/withings/__init__.py | 10 +++++++--- tests/components/withings/test_init.py | 3 ++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 246bcc134d0..597517693c0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at from __future__ import annotations from collections.abc import Awaitable, Callable +import contextlib from typing import Any from aiohttp.hdrs import METH_HEAD, METH_POST @@ -214,9 +215,12 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: - webhook_url = await cloud.async_create_cloudhook( - hass, entry.data[CONF_WEBHOOK_ID] - ) + webhook_id = entry.data[CONF_WEBHOOK_ID] + # Some users already have their webhook as cloudhook. + # We remove them to be sure we can create a new one. + with contextlib.suppress(ValueError): + await cloud.async_delete_cloudhook(hass, webhook_id) + webhook_url = await cloud.async_create_cloudhook(hass, webhook_id) data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} hass.config_entries.async_update_entry(entry, data=data) return webhook_url diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index a3918a6ff19..1c562182ae7 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -421,6 +421,7 @@ async def test_setup_with_cloud( assert hass.components.cloud.async_active_subscription() is True assert hass.components.cloud.async_is_connected() is True fake_create_cloudhook.assert_called_once() + fake_delete_cloudhook.assert_called_once() assert ( hass.config_entries.async_entries("withings")[0].data["cloudhook_url"] @@ -432,7 +433,7 @@ async def test_setup_with_cloud( for config_entry in hass.config_entries.async_entries("withings"): await hass.config_entries.async_remove(config_entry.entry_id) - fake_delete_cloudhook.assert_called_once() + fake_delete_cloudhook.call_count == 2 await hass.async_block_till_done() assert not hass.config_entries.async_entries(DOMAIN) From f7aad4a9e65a17c755437f408ea70940864266fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 6 Oct 2023 14:22:56 +0300 Subject: [PATCH 225/968] Call pytest as python3 -m pytest (#101185) --- .vscode/tasks.json | 6 +++--- script/lint_and_test.py | 9 ++++++++- script/scaffold/__main__.py | 11 +++++++++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index c767647f821..b8cb8a4e61a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ { "label": "Pytest", "type": "shell", - "command": "pytest --timeout=10 tests", + "command": "python3 -m pytest --timeout=10 tests", "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", @@ -31,7 +31,7 @@ { "label": "Pytest (changed tests only)", "type": "shell", - "command": "pytest --timeout=10 --picked", + "command": "python3 -m pytest --timeout=10 --picked", "group": { "kind": "test", "isDefault": true @@ -75,7 +75,7 @@ "label": "Code Coverage", "detail": "Generate code coverage report for a given integration.", "type": "shell", - "command": "pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", + "command": "python3 -m pytest ./tests/components/${input:integrationName}/ --cov=homeassistant.components.${input:integrationName} --cov-report term-missing --durations-min=1 --durations=0 --numprocesses=auto", "group": { "kind": "test", "isDefault": true diff --git a/script/lint_and_test.py b/script/lint_and_test.py index ee37841b056..ee28d4765d6 100755 --- a/script/lint_and_test.py +++ b/script/lint_and_test.py @@ -223,7 +223,14 @@ async def main(): return code, _ = await async_exec( - "pytest", "-vv", "--force-sugar", "--", *test_files, display=True + "python3", + "-m", + "pytest", + "-vv", + "--force-sugar", + "--", + *test_files, + display=True, ) print("=============================") diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py index 42a8355db59..8dafd8fa802 100644 --- a/script/scaffold/__main__.py +++ b/script/scaffold/__main__.py @@ -103,9 +103,16 @@ def main(): if args.develop: print("Running tests") - print(f"$ pytest -vvv tests/components/{info.domain}") + print(f"$ python3 -m pytest -vvv tests/components/{info.domain}") subprocess.run( - ["pytest", "-vvv", f"tests/components/{info.domain}"], check=True + [ + "python3", + "-m", + "pytest", + "-vvv", + f"tests/components/{info.domain}", + ], + check=True, ) print() From 79eaaec1a822ac450e0dbcf936490213b9f268d3 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 6 Oct 2023 13:23:32 +0200 Subject: [PATCH 226/968] Limit waze_travel_time to 0.5call/s over all entries (#101514) --- homeassistant/components/waze_travel_time/__init__.py | 6 ++++++ homeassistant/components/waze_travel_time/const.py | 1 + homeassistant/components/waze_travel_time/sensor.py | 11 ++++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py index 806672b3608..beaa2ecc69a 100644 --- a/homeassistant/components/waze_travel_time/__init__.py +++ b/homeassistant/components/waze_travel_time/__init__.py @@ -1,13 +1,19 @@ """The waze_travel_time component.""" +import asyncio + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from .const import DOMAIN, SEMAPHORE + PLATFORMS = [Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Load the saved entities.""" + if SEMAPHORE not in hass.data.setdefault(DOMAIN, {}): + hass.data.setdefault(DOMAIN, {})[SEMAPHORE] = asyncio.Semaphore(1) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/waze_travel_time/const.py b/homeassistant/components/waze_travel_time/const.py index 698ba5a63b2..572676e1966 100644 --- a/homeassistant/components/waze_travel_time/const.py +++ b/homeassistant/components/waze_travel_time/const.py @@ -2,6 +2,7 @@ from __future__ import annotations DOMAIN = "waze_travel_time" +SEMAPHORE = "semaphore" CONF_DESTINATION = "destination" CONF_ORIGIN = "origin" diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index bf3544de8a9..b54d723f95d 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -43,6 +43,7 @@ from .const import ( DEFAULT_NAME, DOMAIN, IMPERIAL_UNITS, + SEMAPHORE, ) _LOGGER = logging.getLogger(__name__) @@ -51,7 +52,7 @@ SCAN_INTERVAL = timedelta(minutes=5) PARALLEL_UPDATES = 1 -MS_BETWEEN_API_CALLS = 0.5 +SECONDS_BETWEEN_API_CALLS = 0.5 async def async_setup_entry( @@ -148,8 +149,12 @@ class WazeTravelTime(SensorEntity): _LOGGER.debug("Fetching Route for %s", self._attr_name) self._waze_data.origin = find_coordinates(self.hass, self._origin) self._waze_data.destination = find_coordinates(self.hass, self._destination) - await self._waze_data.async_update() - await asyncio.sleep(MS_BETWEEN_API_CALLS) + await self.hass.data[DOMAIN][SEMAPHORE].acquire() + try: + await self._waze_data.async_update() + await asyncio.sleep(SECONDS_BETWEEN_API_CALLS) + finally: + self.hass.data[DOMAIN][SEMAPHORE].release() class WazeTravelTimeData: From fd2edf6c0ab2835726db8a687a300113de25fe1b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 6 Oct 2023 13:33:28 +0200 Subject: [PATCH 227/968] Allow remove devices in Scrape (#101229) --- homeassistant/components/scrape/__init__.py | 17 +++++++- tests/components/scrape/test_init.py | 48 ++++++++++++++++++++- 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index bdfa3fd9c5a..e96260139da 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -18,8 +18,9 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import discovery, entity_registry as er import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.trigger_template_entity import ( CONF_AVAILABILITY, TEMPLATE_SENSOR_BASE_SCHEMA, @@ -120,3 +121,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Scrape config entry from a device.""" + entity_registry = er.async_get(hass) + for identifier in device.identifiers: + if identifier[0] == DOMAIN and entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, identifier[1] + ): + return False + + return True diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index aa4be4cdef3..638e25a6e05 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -8,13 +8,14 @@ import pytest from homeassistant import config_entries from homeassistant.components.scrape.const import DEFAULT_SCAN_INTERVAL, DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from . import MockRestData, return_integration_config from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator async def test_setup_config(hass: HomeAssistant) -> None: @@ -125,3 +126,48 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert await hass.config_entries.async_unload(loaded_entry.entry_id) await hass.async_block_till_done() assert loaded_entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices( + hass: HomeAssistant, + loaded_entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.entities["sensor.current_version"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, loaded_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=loaded_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, loaded_entry.entry_id + ) + is True + ) From 1c590f8d0eb4e78c027968259b4f1fcc767ccdd6 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 6 Oct 2023 07:35:20 -0400 Subject: [PATCH 228/968] Remove unnecessary defaults from Netatmo sensor (#101528) --- homeassistant/components/netatmo/sensor.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 949c7336ea4..f286e53772c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -87,7 +87,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="temperature", name="Temperature", netatmo_name="temperature", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.TEMPERATURE, @@ -104,7 +103,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( name="CO2", netatmo_name="co2", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, - entity_registry_enabled_default=True, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.CO2, ), @@ -112,7 +110,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="pressure", name="Pressure", netatmo_name="pressure", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPressure.MBAR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.ATMOSPHERIC_PRESSURE, @@ -128,7 +125,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="noise", name="Noise", netatmo_name="noise", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfSoundPressure.DECIBEL, device_class=SensorDeviceClass.SOUND_PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -137,7 +133,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="humidity", name="Humidity", netatmo_name="humidity", - entity_registry_enabled_default=True, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.HUMIDITY, @@ -146,7 +141,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="rain", name="Rain", netatmo_name="rain", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, @@ -164,7 +158,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="sum_rain_24", name="Rain today", netatmo_name="sum_rain_24", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.TOTAL_INCREASING, @@ -173,7 +166,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="battery_percent", name="Battery Percent", netatmo_name="battery", - entity_registry_enabled_default=True, entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -183,7 +175,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="windangle", name="Direction", netatmo_name="wind_direction", - entity_registry_enabled_default=True, icon="mdi:compass-outline", ), NetatmoSensorEntityDescription( @@ -199,7 +190,6 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="windstrength", name="Wind Strength", netatmo_name="wind_strength", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -257,14 +247,12 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = ( key="health_idx", name="Health", netatmo_name="health_idx", - entity_registry_enabled_default=True, icon="mdi:cloud", ), NetatmoSensorEntityDescription( key="power", name="Power", netatmo_name="power", - entity_registry_enabled_default=True, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.POWER, From 3cd4d26eb93f98ffc8d5a466a24201e6bd7f48e0 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Fri, 6 Oct 2023 13:37:48 +0200 Subject: [PATCH 229/968] React on changed firmware version in devolo_home_network (#101513) --- .../devolo_home_network/__init__.py | 21 +++++++++++++++++++ tests/components/devolo_home_network/mock.py | 9 +++++++- .../devolo_home_network/test_update.py | 13 +++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 94e848fe8af..0fee65d57b6 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -54,6 +55,7 @@ async def async_setup_entry( # noqa: C901 hass.data.setdefault(DOMAIN, {}) zeroconf_instance = await zeroconf.async_get_async_instance(hass) async_client = get_async_client(hass) + device_registry = dr.async_get(hass) try: device = Device( @@ -73,6 +75,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_check_firmware_available() except DeviceUnavailable as err: @@ -81,6 +84,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet + update_sw_version(device_registry, device) try: return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: @@ -89,6 +93,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_guest_wifi_status() -> WifiGuestAccessGet: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: @@ -99,6 +104,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_led_status() -> bool: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_led_setting() except DeviceUnavailable as err: @@ -107,6 +113,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: @@ -115,6 +122,7 @@ async def async_setup_entry( # noqa: C901 async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: """Fetch data from API endpoint.""" assert device.device + update_sw_version(device_registry, device) try: return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: @@ -211,3 +219,16 @@ def platforms(device: Device) -> set[Platform]: if device.device and "update" in device.device.features: supported_platforms.add(Platform.UPDATE) return supported_platforms + + +@callback +def update_sw_version(device_registry: dr.DeviceRegistry, device: Device) -> None: + """Update device registry with new firmware version.""" + if ( + device_entry := device_registry.async_get_device( + identifiers={(DOMAIN, str(device.serial_number))} + ) + ) and device_entry.sw_version != device.firmware_version: + device_registry.async_update_device( + device_id=device_entry.id, sw_version=device.firmware_version + ) diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 80d1348cf0f..612df4da2e0 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -31,12 +31,18 @@ class MockDevice(Device): ) -> None: """Bring mock in a well defined state.""" super().__init__(ip, zeroconf_instance) + self._firmware_version = DISCOVERY_INFO.properties["FirmwareVersion"] self.reset() @property def firmware_version(self) -> str: """Mock firmware version currently installed.""" - return DISCOVERY_INFO.properties["FirmwareVersion"] + return self._firmware_version + + @firmware_version.setter + def firmware_version(self, version: str) -> None: + """Mock firmware version currently installed.""" + self._firmware_version = version async def async_connect( self, session_instance: httpx.AsyncClient | None = None @@ -49,6 +55,7 @@ class MockDevice(Device): def reset(self): """Reset mock to starting point.""" + self._firmware_version = DISCOVERY_INFO.properties["FirmwareVersion"] self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) self.device.async_check_firmware_available = AsyncMock( diff --git a/tests/components/devolo_home_network/test_update.py b/tests/components/devolo_home_network/test_update.py index 2f8e3fcbc2e..d80e9133a0a 100644 --- a/tests/components/devolo_home_network/test_update.py +++ b/tests/components/devolo_home_network/test_update.py @@ -14,9 +14,10 @@ from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import configure_integration +from .const import FIRMWARE_UPDATE_AVAILABLE from .mock import MockDevice from tests.common import async_fire_time_changed @@ -38,6 +39,7 @@ async def test_update_setup(hass: HomeAssistant) -> None: async def test_update_firmware( hass: HomeAssistant, mock_device: MockDevice, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, freezer: FrozenDateTimeFactory, snapshot: SnapshotAssertion, @@ -62,6 +64,9 @@ async def test_update_firmware( assert mock_device.device.async_start_firmware_update.call_count == 1 # Emulate state change + mock_device.firmware_version = FIRMWARE_UPDATE_AVAILABLE.new_firmware_version.split( + "_" + )[0] mock_device.device.async_check_firmware_available.return_value = ( UpdateFirmwareCheck(result=UPDATE_NOT_AVAILABLE) ) @@ -73,6 +78,12 @@ async def test_update_firmware( assert state is not None assert state.state == STATE_OFF + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info is not None + assert device_info.sw_version == mock_device.firmware_version + await hass.config_entries.async_unload(entry.entry_id) From d5f07ef45fd7597126812f4434b07a47dc985ca8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 6 Oct 2023 13:44:07 +0200 Subject: [PATCH 230/968] Add override decorators to sensor (#94998) --- homeassistant/components/sensor/__init__.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2cab631d1f0..55d2be6dfc6 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,6 +11,8 @@ import logging from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final +from typing_extensions import override + from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -262,6 +264,7 @@ class SensorEntity(Entity): return self.device_class not in (None, SensorDeviceClass.ENUM) @property + @override def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): @@ -317,6 +320,7 @@ class SensorEntity(Entity): return None @property + @override def capability_attributes(self) -> Mapping[str, Any] | None: """Return the capability attributes.""" if state_class := self.state_class: @@ -362,6 +366,7 @@ class SensorEntity(Entity): @final @property + @override def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: @@ -439,6 +444,7 @@ class SensorEntity(Entity): @final @property + @override def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" # Highest priority, for registered entities: unit set by user,with fallback to @@ -468,6 +474,7 @@ class SensorEntity(Entity): @final @property + @override def state(self) -> Any: """Return the state of the sensor and perform unit conversions, if needed.""" native_unit_of_measurement = self.native_unit_of_measurement From 835982ebe59e6d5e6c5afc15e76e54742fc52bea Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 13:54:21 +0200 Subject: [PATCH 231/968] Migrate Samsung TV to has entity name (#96751) * Migrate Samsung TV to has entity name * Fix test * Fix tests --------- Co-authored-by: Simone Chemelli --- homeassistant/components/samsungtv/entity.py | 9 +++------ homeassistant/components/samsungtv/media_player.py | 1 + homeassistant/components/samsungtv/remote.py | 1 + tests/components/samsungtv/snapshots/test_init.ambr | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index 2b6373efc24..dbfd7c44730 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -1,8 +1,6 @@ """Base SamsungTV Entity.""" from __future__ import annotations -from typing import cast - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_MODEL, CONF_NAME from homeassistant.helpers import device_registry as dr @@ -16,17 +14,16 @@ from .const import CONF_MANUFACTURER, DOMAIN class SamsungTVEntity(Entity): """Defines a base SamsungTV entity.""" + _attr_has_entity_name = True + def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: """Initialize the SamsungTV entity.""" self._bridge = bridge self._mac = config_entry.data.get(CONF_MAC) - self._attr_name = config_entry.data.get(CONF_NAME) # Fallback for legacy models that doesn't have a API to retrieve MAC or SerialNumber self._attr_unique_id = config_entry.unique_id or config_entry.entry_id self._attr_device_info = DeviceInfo( - # Instead of setting the device name to the entity name, samsungtv - # should be updated to set has_entity_name = True - name=cast(str | None, self.name), + name=config_entry.data.get(CONF_NAME), manufacturer=config_entry.data.get(CONF_MANUFACTURER), model=config_entry.data.get(CONF_MODEL), ) diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 87174b13dd6..14589274da6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -72,6 +72,7 @@ class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] + _attr_name = None _attr_device_class = MediaPlayerDeviceClass.TV def __init__( diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py index 22857d96659..bbe65d2ac82 100644 --- a/homeassistant/components/samsungtv/remote.py +++ b/homeassistant/components/samsungtv/remote.py @@ -24,6 +24,7 @@ async def async_setup_entry( class SamsungTVRemote(SamsungTVEntity, RemoteEntity): """Device that sends commands to a SamsungTV.""" + _attr_name = None _attr_should_poll = False async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index f8b11bd864a..25d8edb15ac 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -36,7 +36,7 @@ 'domain': 'media_player', 'entity_category': None, 'entity_id': 'media_player.any', - 'has_entity_name': False, + 'has_entity_name': True, 'hidden_by': None, 'icon': None, 'id': , @@ -45,7 +45,7 @@ }), 'original_device_class': , 'original_icon': None, - 'original_name': 'any', + 'original_name': None, 'platform': 'samsungtv', 'supported_features': , 'translation_key': None, From 62802dd4877f09c9404d38a4d9797b7bf3130811 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 6 Oct 2023 08:01:21 -0400 Subject: [PATCH 232/968] Add entity translations to Goalzero (#95310) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/goalzero/binary_sensor.py | 7 +-- homeassistant/components/goalzero/sensor.py | 26 ++++---- .../components/goalzero/strings.json | 62 +++++++++++++++++++ homeassistant/components/goalzero/switch.py | 6 +- tests/components/goalzero/test_sensor.py | 14 ++--- 5 files changed, 87 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index db96fa0539b..6d53628f21e 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -21,23 +21,22 @@ PARALLEL_UPDATES = 0 BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( key="backlight", - name="Backlight", + translation_key="backlight", icon="mdi:clock-digital", ), BinarySensorEntityDescription( key="app_online", - name="App online", + translation_key="app_online", device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, ), BinarySensorEntityDescription( key="isCharging", - name="Charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), BinarySensorEntityDescription( key="inputDetected", - name="Input detected", + translation_key="input_detected", device_class=BinarySensorDeviceClass.POWER, ), ) diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 9001824d678..b9a83453d7f 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -31,14 +31,14 @@ from .entity import GoalZeroEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="wattsIn", - name="Watts in", + translation_key="watts_in", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ampsIn", - name="Amps in", + translation_key="amps_in", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -46,14 +46,14 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="wattsOut", - name="Watts out", + translation_key="watts_out", device_class=SensorDeviceClass.POWER, native_unit_of_measurement=UnitOfPower.WATT, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ampsOut", - name="Amps out", + translation_key="amps_out", device_class=SensorDeviceClass.CURRENT, native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +61,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="whOut", - name="Wh out", + translation_key="wh_out", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, @@ -69,40 +69,38 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="whStored", - name="Wh stored", + translation_key="wh_stored", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, state_class=SensorStateClass.TOTAL, ), SensorEntityDescription( key="volts", - name="Volts", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, entity_registry_enabled_default=False, ), SensorEntityDescription( key="socPercent", - name="State of charge percent", + translation_key="soc_percent", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( key="timeToEmptyFull", - name="Time to empty/full", + translation_key="time_to_empty_full", device_class=SensorDeviceClass.DURATION, native_unit_of_measurement=UnitOfTime.MINUTES, ), SensorEntityDescription( key="temperature", - name="Temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="wifiStrength", - name="Wi-Fi strength", + translation_key="wifi_strength", device_class=SensorDeviceClass.SIGNAL_STRENGTH, native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, entity_registry_enabled_default=False, @@ -110,20 +108,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ), SensorEntityDescription( key="timestamp", - name="Total run time", + translation_key="timestamp", native_unit_of_measurement=UnitOfTime.SECONDS, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="ssid", - name="Wi-Fi SSID", + translation_key="ssid", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), SensorEntityDescription( key="ipAddr", - name="IP address", + translation_key="ip_addr", entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), diff --git a/homeassistant/components/goalzero/strings.json b/homeassistant/components/goalzero/strings.json index 619b379c7a3..d94f5219607 100644 --- a/homeassistant/components/goalzero/strings.json +++ b/homeassistant/components/goalzero/strings.json @@ -22,5 +22,67 @@ "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "binary_sensor": { + "backlight": { + "name": "Backlight" + }, + "app_online": { + "name": "App online" + }, + "input_detected": { + "name": "Input detected" + } + }, + "sensor": { + "watts_in": { + "name": "Power in" + }, + "amps_in": { + "name": "Current in" + }, + "watts_out": { + "name": "Power out" + }, + "amps_out": { + "name": "Current out" + }, + "wh_out": { + "name": "Energy out" + }, + "wh_stored": { + "name": "Energy stored" + }, + "soc_percent": { + "name": "State of charge percent" + }, + "time_to_empty_full": { + "name": "Time to empty/full" + }, + "wifi_strength": { + "name": "Wi-Fi strength" + }, + "timestamp": { + "name": "Total run time" + }, + "ssid": { + "name": "Wi-Fi SSID" + }, + "ip_addr": { + "name": "IP address" + } + }, + "switch": { + "v12_port_status": { + "name": "12V port status" + }, + "usb_port_status": { + "name": "USB port status" + }, + "ac_port_status": { + "name": "AC port status" + } + } } } diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index ac4872bba32..30680c6ff72 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -14,15 +14,15 @@ from .entity import GoalZeroEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( key="v12PortStatus", - name="12V port status", + translation_key="v12_port_status", ), SwitchEntityDescription( key="usbPortStatus", - name="USB port status", + translation_key="usb_port_status", ), SwitchEntityDescription( key="acPortStatus", - name="AC port status", + translation_key="ac_port_status", ), ) diff --git a/tests/components/goalzero/test_sensor.py b/tests/components/goalzero/test_sensor.py index 90b1489803a..d36d692422e 100644 --- a/tests/components/goalzero/test_sensor.py +++ b/tests/components/goalzero/test_sensor.py @@ -33,41 +33,41 @@ async def test_sensors( """Test we get sensor data.""" await async_init_integration(hass, aioclient_mock) - state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_in") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_power_in") assert state.state == "0.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_in") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_current_in") assert state.state == "0.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_watts_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_power_out") assert state.state == "50.5" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_amps_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_current_out") assert state.state == "2.1" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.CURRENT assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricCurrent.AMPERE ) assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_out") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_energy_out") assert state.state == "5.23" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - state = hass.states.get(f"sensor.{DEFAULT_NAME}_wh_stored") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_energy_stored") assert state.state == "1330" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.WATT_HOUR assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL - state = hass.states.get(f"sensor.{DEFAULT_NAME}_volts") + state = hass.states.get(f"sensor.{DEFAULT_NAME}_voltage") assert state.state == "12.0" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.VOLTAGE assert ( From d2c842ee0c1346147be722b4c90e97b5f7196fd8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 6 Oct 2023 14:10:10 +0200 Subject: [PATCH 233/968] Modbus, wrong length when reading strings (#101529) --- homeassistant/components/modbus/validators.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index ca08ace853a..bef58b3fa56 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -84,7 +84,7 @@ DEFAULT_STRUCT_FORMAT = { DataType.INT64: ENTRY("q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.UINT64: ENTRY("Q", 4, PARM_IS_LEGAL(False, False, True, True, True)), DataType.FLOAT64: ENTRY("d", 4, PARM_IS_LEGAL(False, False, True, True, True)), - DataType.STRING: ENTRY("s", 1, PARM_IS_LEGAL(True, False, False, False, False)), + DataType.STRING: ENTRY("s", -1, PARM_IS_LEGAL(True, False, False, False, False)), DataType.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } @@ -143,7 +143,8 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes" ) else: - config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count + if data_type != DataType.STRING: + config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count if slave_count: structure = ( f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" From 10dcdbf5379dd4450501557f9c6adc6eb71b2cc3 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Fri, 6 Oct 2023 08:13:59 -0400 Subject: [PATCH 234/968] Correct doc strings for Hassio component (#101530) --- homeassistant/components/hassio/update.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 285a2663d92..8a3199a1121 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -181,17 +181,17 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION] @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/homeassistant/icon.png" @property @@ -224,12 +224,12 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION] @property @@ -247,7 +247,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity): @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/hassio/icon.png" async def async_install( @@ -274,17 +274,17 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): @property def latest_version(self) -> str: - """Return native value of entity.""" + """Return the latest version.""" return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST] @property def installed_version(self) -> str: - """Return native value of entity.""" + """Return the installed version.""" return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION] @property def entity_picture(self) -> str | None: - """Return the iconof the entity.""" + """Return the icon of the entity.""" return "https://brands.home-assistant.io/homeassistant/icon.png" @property From adc7fc0ee49d0a0c3984b83addaabf41ec2b2243 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 6 Oct 2023 14:30:11 +0200 Subject: [PATCH 235/968] Fix GDACS import issue creation (#97667) --- homeassistant/components/gdacs/__init__.py | 18 +------- homeassistant/components/gdacs/config_flow.py | 41 ++++++++++++++++++- tests/components/gdacs/test_config_flow.py | 29 ++++++++++++- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index f25341455bb..557af9474ed 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -13,11 +13,10 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, UnitOfLength, ) -from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import DistanceConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -78,21 +77,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: }, ) ) - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.2.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Global Disaster Alert and Coordination System", - }, - ) - return True diff --git a/homeassistant/components/gdacs/config_flow.py b/homeassistant/components/gdacs/config_flow.py index c49626557f4..fb2b8416937 100644 --- a/homeassistant/components/gdacs/config_flow.py +++ b/homeassistant/components/gdacs/config_flow.py @@ -10,7 +10,10 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN +from homeassistant.data_entry_flow import AbortFlow, FlowResultType from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_CATEGORIES, DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN @@ -32,7 +35,23 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" - return await self.async_step_user(import_config) + result = await self.async_step_user(import_config) + if result["type"] == FlowResultType.CREATE_ENTRY: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Global Disaster Alert and Coordination System", + }, + ) + return result async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" @@ -48,7 +67,25 @@ class GdacsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" await self.async_set_unique_id(identifier) - self._abort_if_unique_id_configured() + try: + self._abort_if_unique_id_configured() + except AbortFlow: + if self.context["source"] == config_entries.SOURCE_IMPORT: + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.2.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Global Disaster Alert and Coordination System", + }, + ) + raise scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.total_seconds() diff --git a/tests/components/gdacs/test_config_flow.py b/tests/components/gdacs/test_config_flow.py index 88641b69bd2..f8dfa0cd7fd 100644 --- a/tests/components/gdacs/test_config_flow.py +++ b/tests/components/gdacs/test_config_flow.py @@ -12,7 +12,8 @@ from homeassistant.const import ( CONF_RADIUS, CONF_SCAN_INTERVAL, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir @pytest.fixture(name="gdacs_setup", autouse=True) @@ -66,6 +67,32 @@ async def test_step_import(hass: HomeAssistant) -> None: CONF_CATEGORIES: ["Drought", "Earthquake"], } + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" + ) + assert issue.translation_key == "deprecated_yaml" + + +async def test_step_import_already_exist( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_gdacs" + ) + assert issue.translation_key == "deprecated_yaml" + async def test_step_user(hass: HomeAssistant) -> None: """Test that the user step works.""" From 96aba1c1a6604abbe4b72c520e28197e53258a40 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Fri, 6 Oct 2023 08:42:08 -0400 Subject: [PATCH 236/968] Add tests to Hydrawise (#101110) * Add tests to Hydrawise * Update tests/components/hydrawise/test_binary_sensor.py Co-authored-by: Joost Lekkerkerker * Changes requested during review --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 6 -- .../components/hydrawise/binary_sensor.py | 2 +- homeassistant/components/hydrawise/sensor.py | 2 +- homeassistant/components/hydrawise/switch.py | 2 +- tests/components/hydrawise/conftest.py | 3 + .../hydrawise/test_binary_sensor.py | 53 ++++++++++++ tests/components/hydrawise/test_init.py | 57 +++++++++++++ tests/components/hydrawise/test_sensor.py | 36 ++++++++ tests/components/hydrawise/test_switch.py | 85 +++++++++++++++++++ 9 files changed, 237 insertions(+), 9 deletions(-) create mode 100644 tests/components/hydrawise/test_binary_sensor.py create mode 100644 tests/components/hydrawise/test_init.py create mode 100644 tests/components/hydrawise/test_sensor.py create mode 100644 tests/components/hydrawise/test_switch.py diff --git a/.coveragerc b/.coveragerc index 7f474426fa2..599556d5d57 100644 --- a/.coveragerc +++ b/.coveragerc @@ -541,12 +541,6 @@ omit = homeassistant/components/hvv_departures/__init__.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py - homeassistant/components/hydrawise/__init__.py - homeassistant/components/hydrawise/binary_sensor.py - homeassistant/components/hydrawise/const.py - homeassistant/components/hydrawise/coordinator.py - homeassistant/components/hydrawise/sensor.py - homeassistant/components/hydrawise/switch.py homeassistant/components/ialarm/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 1c40b16926d..30096a9bf97 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -57,7 +57,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index a5bd9251a33..ef98ce99bfb 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -59,7 +59,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 8cdb5b67561..d1ea0233145 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -65,7 +65,7 @@ def setup_platform( ) -> None: """Set up a sensor for a Hydrawise device.""" # We don't need to trigger import flow from here as it's triggered from `__init__.py` - return + return # pragma: no cover async def async_setup_entry( diff --git a/tests/components/hydrawise/conftest.py b/tests/components/hydrawise/conftest.py index 30989018152..4a6c8372e57 100644 --- a/tests/components/hydrawise/conftest.py +++ b/tests/components/hydrawise/conftest.py @@ -33,6 +33,9 @@ def mock_pydrawise( mock_pydrawise.return_value.current_controller = mock_controller mock_pydrawise.return_value.controller_status = {"relays": mock_zones} mock_pydrawise.return_value.relays = mock_zones + mock_pydrawise.return_value.relays_by_zone_number = { + r["relay"]: r for r in mock_zones + } yield mock_pydrawise.return_value diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py new file mode 100644 index 00000000000..ab88c5fb750 --- /dev/null +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -0,0 +1,53 @@ +"""Test Hydrawise binary_sensor.""" + +from datetime import timedelta +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary_sensor states.""" + # Make the coordinator refresh data. + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity is not None + assert connectivity.state == "on" + + watering1 = hass.states.get("binary_sensor.zone_one_watering") + assert watering1 is not None + assert watering1.state == "off" + + watering2 = hass.states.get("binary_sensor.zone_two_watering") + assert watering2 is not None + assert watering2.state == "on" + + +async def test_update_data_fails( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + mock_pydrawise: Mock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that no data from the API sets the correct connectivity.""" + # Make the coordinator refresh data. + mock_pydrawise.update_controller_info.return_value = None + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + connectivity = hass.states.get("binary_sensor.home_controller_connectivity") + assert connectivity is not None + assert connectivity.state == "unavailable" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py new file mode 100644 index 00000000000..87c158ec0b9 --- /dev/null +++ b/tests/components/hydrawise/test_init.py @@ -0,0 +1,57 @@ +"""Tests for the Hydrawise integration.""" + +from unittest.mock import Mock, patch + +from requests.exceptions import HTTPError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant +import homeassistant.helpers.issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: + """Test that setup with a YAML config triggers an import and warning.""" + mock_pydrawise.customer_id = 12345 + mock_pydrawise.status = "unknown" + config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} + assert await async_setup_component(hass, "hydrawise", config) + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, "deprecated_yaml_hydrawise" + ) + assert issue.translation_key == "deprecated_yaml" + + +async def test_connect_retry( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that a connection error triggers a retry.""" + with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: + mock_api.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_api.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_setup_no_data( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that no data from the API triggers a retry.""" + with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: + mock_api.return_value.controller_info = {} + mock_api.return_value.controller_status = None + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_api.assert_called_once() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py new file mode 100644 index 00000000000..b7c60f333f4 --- /dev/null +++ b/tests/components/hydrawise/test_sensor.py @@ -0,0 +1,36 @@ +"""Test Hydrawise sensor.""" + +from datetime import timedelta + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor states.""" + # Make the coordinator refresh data. + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + watering_time1 = hass.states.get("sensor.zone_one_watering_time") + assert watering_time1 is not None + assert watering_time1.state == "0" + + watering_time2 = hass.states.get("sensor.zone_two_watering_time") + assert watering_time2 is not None + assert watering_time2.state == "29" + + next_cycle = hass.states.get("sensor.zone_one_next_cycle") + assert next_cycle is not None + assert next_cycle.state == "2023-10-04T19:52:27+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py new file mode 100644 index 00000000000..615a336ee5f --- /dev/null +++ b/tests/components/hydrawise/test_switch.py @@ -0,0 +1,85 @@ +"""Test Hydrawise switch.""" + +from datetime import timedelta +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.hydrawise.const import SCAN_INTERVAL +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_states( + hass: HomeAssistant, + mock_added_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test switch states.""" + # Make the coordinator refresh data. + freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + watering1 = hass.states.get("switch.zone_one_manual_watering") + assert watering1 is not None + assert watering1.state == "off" + + watering2 = hass.states.get("switch.zone_two_manual_watering") + assert watering2 is not None + assert watering2.state == "on" + + auto_watering1 = hass.states.get("switch.zone_one_automatic_watering") + assert auto_watering1 is not None + assert auto_watering1.state == "on" + + auto_watering2 = hass.states.get("switch.zone_two_automatic_watering") + assert auto_watering2 is not None + assert auto_watering2.state == "off" + + +async def test_manual_watering_services( + hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test Manual Watering services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, + blocking=True, + ) + mock_pydrawise.run_zone.assert_called_once_with(15, 1) + mock_pydrawise.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.zone_one_manual_watering"}, + blocking=True, + ) + mock_pydrawise.run_zone.assert_called_once_with(0, 1) + + +async def test_auto_watering_services( + hass: HomeAssistant, mock_added_config_entry: MockConfigEntry, mock_pydrawise: Mock +) -> None: + """Test Automatic Watering services.""" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, + blocking=True, + ) + mock_pydrawise.suspend_zone.assert_called_once_with(365, 1) + mock_pydrawise.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + service_data={ATTR_ENTITY_ID: "switch.zone_one_automatic_watering"}, + blocking=True, + ) + mock_pydrawise.suspend_zone.assert_called_once_with(0, 1) From 99824833951cf97920982be2abcbb6dfef64a2de Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 6 Oct 2023 14:08:51 +0100 Subject: [PATCH 237/968] Add media player to System Bridge integration (#97532) Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/system_bridge/__init__.py | 1 + .../components/system_bridge/const.py | 1 + .../components/system_bridge/coordinator.py | 2 + .../components/system_bridge/media_player.py | 264 ++++++++++++++++++ .../components/system_bridge/strings.json | 5 + 6 files changed, 274 insertions(+) create mode 100644 homeassistant/components/system_bridge/media_player.py diff --git a/.coveragerc b/.coveragerc index 599556d5d57..8f9ad53ef61 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1294,6 +1294,7 @@ omit = homeassistant/components/system_bridge/__init__.py homeassistant/components/system_bridge/binary_sensor.py homeassistant/components/system_bridge/coordinator.py + homeassistant/components/system_bridge/media_player.py homeassistant/components/system_bridge/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d13f5bcbdde..90a6f0659ef 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -46,6 +46,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.MEDIA_PLAYER, Platform.NOTIFY, Platform.SENSOR, ] diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index c71ee86c920..77ff953b67d 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -8,6 +8,7 @@ MODULES = [ "disk", "display", "gpu", + "media", "memory", "system", ] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 145e01ed29a..a4b016d49bd 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -19,6 +19,7 @@ from systembridgeconnector.models.disk import Disk from systembridgeconnector.models.display import Display from systembridgeconnector.models.get_data import GetData from systembridgeconnector.models.gpu import Gpu +from systembridgeconnector.models.media import Media from systembridgeconnector.models.media_directories import MediaDirectories from systembridgeconnector.models.media_files import File as MediaFile, MediaFiles from systembridgeconnector.models.media_get_file import MediaGetFile @@ -50,6 +51,7 @@ class SystemBridgeCoordinatorData(BaseModel): disk: Disk = None display: Display = None gpu: Gpu = None + media: Media = None memory: Memory = None system: System = None diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py new file mode 100644 index 00000000000..c0d58c74c61 --- /dev/null +++ b/homeassistant/components/system_bridge/media_player.py @@ -0,0 +1,264 @@ +"""Support for System Bridge media players.""" +from __future__ import annotations + +import datetime as dt +from typing import Final + +from systembridgeconnector.models.media_control import ( + Action as MediaAction, + MediaControl, +) + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityDescription, + MediaPlayerEntityFeature, + MediaPlayerState, + RepeatMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import SystemBridgeEntity +from .const import DOMAIN +from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator + +STATUS_CHANGING: Final[str] = "CHANGING" +STATUS_STOPPED: Final[str] = "STOPPED" +STATUS_PLAYING: Final[str] = "PLAYING" +STATUS_PAUSED: Final[str] = "PAUSED" + +REPEAT_NONE: Final[str] = "NONE" +REPEAT_TRACK: Final[str] = "TRACK" +REPEAT_LIST: Final[str] = "LIST" + +MEDIA_STATUS_MAP: Final[dict[str, MediaPlayerState]] = { + STATUS_CHANGING: MediaPlayerState.IDLE, + STATUS_STOPPED: MediaPlayerState.IDLE, + STATUS_PLAYING: MediaPlayerState.PLAYING, + STATUS_PAUSED: MediaPlayerState.PAUSED, +} + +MEDIA_REPEAT_MAP: Final[dict[str, RepeatMode]] = { + REPEAT_NONE: RepeatMode.OFF, + REPEAT_TRACK: RepeatMode.ONE, + REPEAT_LIST: RepeatMode.ALL, +} + +MEDIA_SET_REPEAT_MAP: Final[dict[RepeatMode, int]] = { + RepeatMode.OFF: 0, + RepeatMode.ONE: 1, + RepeatMode.ALL: 2, +} + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up System Bridge media players based on a config entry.""" + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + data: SystemBridgeCoordinatorData = coordinator.data + + if data.media is not None: + async_add_entities( + [ + SystemBridgeMediaPlayer( + coordinator, + MediaPlayerEntityDescription( + key="media", + translation_key="media", + icon="mdi:volume-high", + device_class=MediaPlayerDeviceClass.RECEIVER, + ), + entry.data[CONF_PORT], + ) + ] + ) + + +class SystemBridgeMediaPlayer(SystemBridgeEntity, MediaPlayerEntity): + """Define a System Bridge media player.""" + + entity_description: MediaPlayerEntityDescription + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + description: MediaPlayerEntityDescription, + api_port: int, + ) -> None: + """Initialize.""" + super().__init__( + coordinator, + api_port, + description.key, + ) + self.entity_description = description + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.data.media is not None + + @property + def supported_features(self) -> MediaPlayerEntityFeature: + """Flag media player features that are supported.""" + features = ( + MediaPlayerEntityFeature.REPEAT_SET | MediaPlayerEntityFeature.SHUFFLE_SET + ) + + data = self._systembridge_data + if data.media.is_previous_enabled: + features |= MediaPlayerEntityFeature.PREVIOUS_TRACK + if data.media.is_next_enabled: + features |= MediaPlayerEntityFeature.NEXT_TRACK + if data.media.is_pause_enabled: + features |= MediaPlayerEntityFeature.PAUSE + if data.media.is_play_enabled: + features |= MediaPlayerEntityFeature.PLAY + if data.media.is_stop_enabled: + features |= MediaPlayerEntityFeature.STOP + + return features + + @property + def _systembridge_data(self) -> SystemBridgeCoordinatorData: + """Return data for the entity.""" + return self.coordinator.data + + @property + def state(self) -> MediaPlayerState | None: + """State of the player.""" + if self._systembridge_data.media.status is None: + return None + return MEDIA_STATUS_MAP.get( + self._systembridge_data.media.status, + MediaPlayerState.IDLE, + ) + + @property + def media_duration(self) -> int | None: + """Duration of current playing media in seconds.""" + if self._systembridge_data.media.duration is None: + return None + return int(self._systembridge_data.media.duration) + + @property + def media_position(self) -> int | None: + """Position of current playing media in seconds.""" + if self._systembridge_data.media.position is None: + return None + return int(self._systembridge_data.media.position) + + @property + def media_position_updated_at(self) -> dt.datetime | None: + """When was the position of the current playing media valid.""" + if self._systembridge_data.media.updated_at is None: + return None + return dt.datetime.fromtimestamp(self._systembridge_data.media.updated_at) + + @property + def media_title(self) -> str | None: + """Title of current playing media.""" + return self._systembridge_data.media.title + + @property + def media_artist(self) -> str | None: + """Artist of current playing media, music track only.""" + return self._systembridge_data.media.artist + + @property + def media_album_name(self) -> str | None: + """Album name of current playing media, music track only.""" + return self._systembridge_data.media.album_title + + @property + def media_album_artist(self) -> str | None: + """Album artist of current playing media, music track only.""" + return self._systembridge_data.media.album_artist + + @property + def media_track(self) -> int | None: + """Track number of current playing media, music track only.""" + return self._systembridge_data.media.track_number + + @property + def shuffle(self) -> bool | None: + """Boolean if shuffle is enabled.""" + return self._systembridge_data.media.shuffle + + @property + def repeat(self) -> RepeatMode | None: + """Return current repeat mode.""" + if self._systembridge_data.media.repeat is None: + return RepeatMode.OFF + return MEDIA_REPEAT_MAP.get(self._systembridge_data.media.repeat) + + async def async_media_play(self) -> None: + """Send play command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.play, + ) + ) + + async def async_media_pause(self) -> None: + """Send pause command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.pause, + ) + ) + + async def async_media_stop(self) -> None: + """Send stop command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.stop, + ) + ) + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.previous, + ) + ) + + async def async_media_next_track(self) -> None: + """Send next track command.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.next, + ) + ) + + async def async_set_shuffle( + self, + shuffle: bool, + ) -> None: + """Enable/disable shuffle mode.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.shuffle, + value=shuffle, + ) + ) + + async def async_set_repeat( + self, + repeat: RepeatMode, + ) -> None: + """Set repeat mode.""" + await self.coordinator.websocket_client.media_control( + MediaControl( + action=MediaAction.repeat, + value=MEDIA_SET_REPEAT_MAP.get(repeat), + ) + ) diff --git a/homeassistant/components/system_bridge/strings.json b/homeassistant/components/system_bridge/strings.json index e8565568d20..4df539f11d4 100644 --- a/homeassistant/components/system_bridge/strings.json +++ b/homeassistant/components/system_bridge/strings.json @@ -29,6 +29,11 @@ } }, "entity": { + "media_player": { + "media": { + "name": "Media" + } + }, "sensor": { "boot_time": { "name": "Boot time" From 83c5844c2e27591334d33756f07c8faf1afc43c2 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Fri, 6 Oct 2023 15:37:18 +0200 Subject: [PATCH 238/968] Update LoqedAPI to handle invalid transitions better (#101534) --- homeassistant/components/loqed/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/loqed/manifest.json b/homeassistant/components/loqed/manifest.json index 25d1f15486d..7c682b3189d 100644 --- a/homeassistant/components/loqed/manifest.json +++ b/homeassistant/components/loqed/manifest.json @@ -7,7 +7,7 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/loqed", "iot_class": "local_push", - "requirements": ["loqedAPI==2.1.7"], + "requirements": ["loqedAPI==2.1.8"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 37d5b012372..972e2c18351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1165,7 +1165,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.7 +loqedAPI==2.1.8 # homeassistant.components.luftdaten luftdaten==0.7.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 692fd0b161d..133f7bfb8c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -903,7 +903,7 @@ logi-circle==0.2.3 london-tube-status==0.5 # homeassistant.components.loqed -loqedAPI==2.1.7 +loqedAPI==2.1.8 # homeassistant.components.luftdaten luftdaten==0.7.4 From 5edcd7ef0f54643de03d133816466999d2fc1b5f Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Fri, 6 Oct 2023 16:50:34 +0200 Subject: [PATCH 239/968] Fix Reson sensor enum options mapping (#101380) --- homeassistant/components/renson/sensor.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index b729e2969d6..004be661f02 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -51,16 +51,6 @@ from .const import DOMAIN from .coordinator import RensonCoordinator from .entity import RensonEntity -OPTIONS_MAPPING = { - "Off": "off", - "Level1": "level1", - "Level2": "level2", - "Level3": "level3", - "Level4": "level4", - "Breeze": "breeze", - "Holiday": "holiday", -} - @dataclass class RensonSensorEntityDescriptionMixin: @@ -294,9 +284,9 @@ class RensonSensor(RensonEntity, SensorEntity): if self.raw_format: self._attr_native_value = value elif self.entity_description.device_class == SensorDeviceClass.ENUM: - self._attr_native_value = OPTIONS_MAPPING.get( - self.api.parse_value(value, self.data_type), None - ) + self._attr_native_value = self.api.parse_value( + value, self.data_type + ).lower() else: self._attr_native_value = self.api.parse_value(value, self.data_type) From 8dffff398312e456be716e9edc1e7c731ffc95c8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Oct 2023 05:06:18 -1000 Subject: [PATCH 240/968] Bump HAP-python to 4.8.0 (#101538) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 6f3067d7a78..67f99ad5f8b 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.7.1", + "HAP-python==4.8.0", "fnv-hash-fast==0.4.1", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index 972e2c18351..8aa312e25ce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.7.1 +HAP-python==4.8.0 # homeassistant.components.tasmota HATasmota==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 133f7bfb8c8..3c9ac642a06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.7.1 +HAP-python==4.8.0 # homeassistant.components.tasmota HATasmota==0.7.3 From 1635cbb8a63934c4fb6557cdf7e51133e80ee30b Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 6 Oct 2023 08:08:50 -0700 Subject: [PATCH 241/968] Add a google calendar diagnostics platform (#101175) --- .../components/google/diagnostics.py | 55 +++++++++ tests/components/google/conftest.py | 31 ++++- .../google/snapshots/test_diagnostics.ambr | 70 ++++++++++++ tests/components/google/test_calendar.py | 30 +---- tests/components/google/test_diagnostics.py | 106 ++++++++++++++++++ 5 files changed, 262 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/google/diagnostics.py create mode 100644 tests/components/google/snapshots/test_diagnostics.ambr create mode 100644 tests/components/google/test_diagnostics.py diff --git a/homeassistant/components/google/diagnostics.py b/homeassistant/components/google/diagnostics.py new file mode 100644 index 00000000000..0313e61bc8e --- /dev/null +++ b/homeassistant/components/google/diagnostics.py @@ -0,0 +1,55 @@ +"""Provides diagnostics for google calendar.""" + +import datetime +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from .const import DATA_STORE, DOMAIN + +TO_REDACT = { + "id", + "ical_uuid", + "summary", + "description", + "location", + "attendees", + "recurring_event_id", +} + + +def redact_store(data: dict[str, Any]) -> dict[str, Any]: + """Redact personal information from calendar events in the store.""" + id_num = 0 + diagnostics = {} + for store_data in data.values(): + local_store: dict[str, Any] = store_data.get("event_sync", {}) + for calendar_data in local_store.values(): + id_num += 1 + items: dict[str, Any] = calendar_data.get("items", {}) + diagnostics[f"calendar#{id_num}"] = { + "events": [ + async_redact_data(item, TO_REDACT) for item in items.values() + ], + "sync_token_version": calendar_data.get("sync_token_version"), + } + return diagnostics + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + payload: dict[str, Any] = { + "now": dt_util.now().isoformat(), + "timezone": str(dt_util.DEFAULT_TIME_ZONE), + "system_timezone": str(datetime.datetime.now().astimezone().tzinfo), + } + + store = hass.data[DOMAIN][config_entry.entry_id][DATA_STORE] + data = await store.async_load() + payload["store"] = redact_store(data) + return payload diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 57e542e8a21..d938a2f3291 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -58,6 +58,35 @@ TEST_API_CALENDAR = { "defaultReminders": [], } +TEST_EVENT = { + "summary": "Test All Day Event", + "start": {}, + "end": {}, + "location": "Test Cases", + "description": "test event", + "kind": "calendar#event", + "created": "2016-06-23T16:37:57.000Z", + "transparency": "transparent", + "updated": "2016-06-24T01:57:21.045Z", + "reminders": {"useDefault": True}, + "organizer": { + "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", + "displayName": "Organizer Name", + "self": True, + }, + "sequence": 0, + "creator": { + "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", + "displayName": "Organizer Name", + "self": True, + }, + "id": "_c8rinwq863h45qnucyoi43ny8", + "etag": '"2933466882090000"', + "htmlLink": "https://www.google.com/calendar/event?eid=*******", + "iCalUID": "cydrevtfuybguinhomj@google.com", + "status": "confirmed", +} + CLIENT_ID = "client-id" CLIENT_SECRET = "client-secret" @@ -232,7 +261,7 @@ def mock_events_list( @pytest.fixture def mock_events_list_items( mock_events_list: Callable[[dict[str, Any]], None] -) -> Callable[list[[dict[str, Any]]], None]: +) -> Callable[[list[dict[str, Any]]], None]: """Fixture to construct an API response containing event items.""" def _put_items(items: list[dict[str, Any]]) -> None: diff --git a/tests/components/google/snapshots/test_diagnostics.ambr b/tests/components/google/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c19d3a82f74 --- /dev/null +++ b/tests/components/google/snapshots/test_diagnostics.ambr @@ -0,0 +1,70 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'now': '2023-03-13T13:05:00-06:00', + 'store': dict({ + 'calendar#1': dict({ + 'events': list([ + dict({ + 'attendees': '**REDACTED**', + 'attendees_omitted': False, + 'description': '**REDACTED**', + 'end': dict({ + 'date': None, + 'date_time': '2023-03-13T12:30:00-07:00', + 'timezone': None, + }), + 'event_type': 'default', + 'ical_uuid': '**REDACTED**', + 'id': '**REDACTED**', + 'location': '**REDACTED**', + 'original_start_time': None, + 'recurrence': list([ + ]), + 'recurring_event_id': None, + 'start': dict({ + 'date': None, + 'date_time': '2023-03-13T12:00:00-07:00', + 'timezone': None, + }), + 'status': 'confirmed', + 'summary': '**REDACTED**', + 'transparency': 'transparent', + 'visibility': 'default', + }), + dict({ + 'attendees': '**REDACTED**', + 'attendees_omitted': False, + 'description': '**REDACTED**', + 'end': dict({ + 'date': '2022-10-09', + 'date_time': None, + 'timezone': None, + }), + 'event_type': 'default', + 'ical_uuid': '**REDACTED**', + 'id': '**REDACTED**', + 'location': '**REDACTED**', + 'original_start_time': None, + 'recurrence': list([ + 'RRULE:FREQ=WEEKLY', + ]), + 'recurring_event_id': None, + 'start': dict({ + 'date': '2022-10-08', + 'date_time': None, + 'timezone': None, + }), + 'status': 'confirmed', + 'summary': '**REDACTED**', + 'transparency': 'transparent', + 'visibility': 'default', + }), + ]), + 'sync_token_version': 2, + }), + }), + 'system_timezone': 'tzlocal()', + 'timezone': 'America/Regina', + }) +# --- diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index d6431700fca..3a9673441c0 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -23,6 +23,7 @@ from .conftest import ( CALENDAR_ID, TEST_API_ENTITY, TEST_API_ENTITY_NAME, + TEST_EVENT, TEST_YAML_ENTITY, TEST_YAML_ENTITY_NAME, ApiResult, @@ -36,35 +37,6 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator TEST_ENTITY = TEST_API_ENTITY TEST_ENTITY_NAME = TEST_API_ENTITY_NAME -TEST_EVENT = { - "summary": "Test All Day Event", - "start": {}, - "end": {}, - "location": "Test Cases", - "description": "test event", - "kind": "calendar#event", - "created": "2016-06-23T16:37:57.000Z", - "transparency": "transparent", - "updated": "2016-06-24T01:57:21.045Z", - "reminders": {"useDefault": True}, - "organizer": { - "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", - "displayName": "Organizer Name", - "self": True, - }, - "sequence": 0, - "creator": { - "email": "uvrttabwegnui4gtia3vyqb@import.calendar.google.com", - "displayName": "Organizer Name", - "self": True, - }, - "id": "_c8rinwq863h45qnucyoi43ny8", - "etag": '"2933466882090000"', - "htmlLink": "https://www.google.com/calendar/event?eid=*******", - "iCalUID": "cydrevtfuybguinhomj@google.com", - "status": "confirmed", -} - @pytest.fixture(autouse=True) def mock_test_setup( diff --git a/tests/components/google/test_diagnostics.py b/tests/components/google/test_diagnostics.py new file mode 100644 index 00000000000..5ebc683485b --- /dev/null +++ b/tests/components/google/test_diagnostics.py @@ -0,0 +1,106 @@ +"""Tests for diagnostics platform of google calendar.""" +from collections.abc import Callable +from typing import Any + +from aiohttp.test_utils import TestClient +from freezegun import freeze_time +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.auth.models import Credentials +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .conftest import TEST_EVENT, ComponentSetup + +from tests.common import CLIENT_ID, MockConfigEntry, MockUser +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.fixture(autouse=True) +def mock_test_setup( + test_api_calendar, + mock_calendars_list, +): + """Fixture that sets up the default API responses during integration setup.""" + mock_calendars_list({"items": [test_api_calendar]}) + + +async def generate_new_hass_access_token( + hass: HomeAssistant, hass_admin_user: MockUser, hass_admin_credential: Credentials +) -> str: + """Return an access token to access Home Assistant.""" + await hass.auth.async_link_user(hass_admin_user, hass_admin_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_admin_user, CLIENT_ID, credential=hass_admin_credential + ) + return hass.auth.async_create_access_token(refresh_token) + + +def _get_test_client_generator( + hass: HomeAssistant, aiohttp_client: ClientSessionGenerator, new_token: str +): + """Return a test client generator."".""" + + async def auth_client() -> TestClient: + return await aiohttp_client( + hass.http.app, headers={"Authorization": f"Bearer {new_token}"} + ) + + return auth_client + + +@pytest.fixture(autouse=True) +async def setup_diag(hass): + """Set up diagnostics platform.""" + assert await async_setup_component(hass, "diagnostics", {}) + + +@freeze_time("2023-03-13 12:05:00-07:00") +async def test_diagnostics( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_events_list_items: Callable[[list[dict[str, Any]]], None], + hass_admin_user: MockUser, + hass_admin_credential: Credentials, + config_entry: MockConfigEntry, + aiohttp_client: ClientSessionGenerator, + socket_enabled: None, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for the calendar.""" + mock_events_list_items( + [ + { + **TEST_EVENT, + "id": "event-id-1", + "iCalUID": "event-id-1@google.com", + "start": {"dateTime": "2023-03-13 12:00:00-07:00"}, + "end": {"dateTime": "2023-03-13 12:30:00-07:00"}, + }, + { + **TEST_EVENT, + "id": "event-id-2", + "iCalUID": "event-id-2@google.com", + "summary": "All Day Event", + "start": {"date": "2022-10-08"}, + "end": {"date": "2022-10-09"}, + "recurrence": ["RRULE:FREQ=WEEKLY"], + }, + ] + ) + + assert await component_setup() + + # Since we are freezing time only when we enter this test, we need to + # manually create a new token and clients since the token created by + # the fixtures would not be valid. + new_token = await generate_new_hass_access_token( + hass, hass_admin_user, hass_admin_credential + ) + data = await get_diagnostics_for_config_entry( + hass, _get_test_client_generator(hass, aiohttp_client, new_token), config_entry + ) + assert data == snapshot From 86cf2e29b2ba322ceac6d3b24f67c7bfd602a611 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 17:10:19 +0200 Subject: [PATCH 242/968] Cancel callbacks on Withings entry unload (#101536) --- homeassistant/components/withings/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 597517693c0..25965b30ce2 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -161,7 +161,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_name = "Withings" if entry.title != DEFAULT_TITLE: - webhook_name = " ".join([DEFAULT_TITLE, entry.title]) + webhook_name = f"{DEFAULT_TITLE} {entry.title}" webhook_register( hass, @@ -183,14 +183,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: await unregister_webhook(None) - async_call_later(hass, 30, register_webhook) + entry.async_on_unload(async_call_later(hass, 30, register_webhook)) if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): await register_webhook(None) - cloud.async_listen_connection_change(hass, manage_cloudhook) + entry.async_on_unload( + cloud.async_listen_connection_change(hass, manage_cloudhook) + ) else: - async_at_started(hass, register_webhook) + entry.async_on_unload(async_at_started(hass, register_webhook)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) From c8d1a7ff4f6ff09eaf66198bd7117af7a81e312f Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 6 Oct 2023 19:10:32 +0300 Subject: [PATCH 243/968] Remove references to `name` key in android ip webcam (#99590) --- homeassistant/components/android_ip_webcam/camera.py | 10 +--------- homeassistant/components/android_ip_webcam/entity.py | 5 ----- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/homeassistant/components/android_ip_webcam/camera.py b/homeassistant/components/android_ip_webcam/camera.py index 92ff29177dd..a12798a5b91 100644 --- a/homeassistant/components/android_ip_webcam/camera.py +++ b/homeassistant/components/android_ip_webcam/camera.py @@ -5,7 +5,6 @@ from homeassistant.components.mjpeg import MjpegCamera, filter_urllib3_logging from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, - CONF_NAME, CONF_PASSWORD, CONF_USERNAME, HTTP_BASIC_AUTHENTICATION, @@ -39,14 +38,7 @@ class IPWebcamCamera(MjpegCamera): def __init__(self, coordinator: AndroidIPCamDataUpdateCoordinator) -> None: """Initialize the camera.""" - name = None - # keep imported name until YAML is removed - if CONF_NAME in coordinator.config_entry.data: - name = coordinator.config_entry.data[CONF_NAME] - self._attr_has_entity_name = False - super().__init__( - name=name, mjpeg_url=coordinator.cam.mjpeg_url, still_image_url=coordinator.cam.image_url, authentication=HTTP_BASIC_AUTHENTICATION, @@ -56,5 +48,5 @@ class IPWebcamCamera(MjpegCamera): self._attr_unique_id = f"{coordinator.config_entry.entry_id}-camera" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, - name=name or coordinator.config_entry.data[CONF_HOST], + name=coordinator.config_entry.data[CONF_HOST], ) diff --git a/homeassistant/components/android_ip_webcam/entity.py b/homeassistant/components/android_ip_webcam/entity.py index d729da22a9d..e0432ad060c 100644 --- a/homeassistant/components/android_ip_webcam/entity.py +++ b/homeassistant/components/android_ip_webcam/entity.py @@ -19,11 +19,6 @@ class AndroidIPCamBaseEntity(CoordinatorEntity[AndroidIPCamDataUpdateCoordinator ) -> None: """Initialize the base entity.""" super().__init__(coordinator) - if CONF_NAME in coordinator.config_entry.data: - # name is legacy imported from YAML config - # this block can be removed when removing import from YAML - self._attr_name = f"{coordinator.config_entry.data[CONF_NAME]} {self.entity_description.name}" - self._attr_has_entity_name = False self.cam = coordinator.cam self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, From 6359390a78c2016512bcbc81cda20f24a211ae12 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Fri, 6 Oct 2023 18:12:00 +0200 Subject: [PATCH 244/968] Add Eastron virtual integration (#101385) --- homeassistant/components/eastron/__init__.py | 1 + homeassistant/components/eastron/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/eastron/__init__.py create mode 100644 homeassistant/components/eastron/manifest.json diff --git a/homeassistant/components/eastron/__init__.py b/homeassistant/components/eastron/__init__.py new file mode 100644 index 00000000000..1def36bc1cf --- /dev/null +++ b/homeassistant/components/eastron/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Eastron.""" diff --git a/homeassistant/components/eastron/manifest.json b/homeassistant/components/eastron/manifest.json new file mode 100644 index 00000000000..5496c2645c7 --- /dev/null +++ b/homeassistant/components/eastron/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "eastron", + "name": "Eastron", + "integration_type": "virtual", + "supported_by": "homewizard" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8a6ff2e354d..0a4aa220ace 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1281,6 +1281,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "eastron": { + "name": "Eastron", + "integration_type": "virtual", + "supported_by": "homewizard" + }, "easyenergy": { "name": "easyEnergy", "integration_type": "hub", From b2cad2370b8dce30f451a73deb6fe82d805f8db4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 18:21:06 +0200 Subject: [PATCH 245/968] Add Withings webhooks after a slight delay (#101542) --- homeassistant/components/withings/__init__.py | 5 +- tests/components/withings/__init__.py | 13 ++++- .../components/withings/test_binary_sensor.py | 5 +- tests/components/withings/test_init.py | 55 ++++++------------- tests/components/withings/test_sensor.py | 4 +- 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 25965b30ce2..810ad49171c 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -38,7 +38,6 @@ from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi @@ -187,12 +186,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): - await register_webhook(None) + entry.async_on_unload(async_call_later(hass, 1, register_webhook)) entry.async_on_unload( cloud.async_listen_connection_change(hass, manage_cloudhook) ) else: - entry.async_on_unload(async_at_started(hass, register_webhook)) + entry.async_on_unload(async_call_later(hass, 1, register_webhook)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 459deaae4c5..4d9a0e841b7 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1,15 +1,17 @@ """Tests for the withings component.""" from dataclasses import dataclass +from datetime import timedelta from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient +from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @dataclass @@ -53,3 +55,12 @@ async def setup_integration( ) await hass.config_entries.async_setup(config_entry.entry_id) + + +async def prepare_webhook_setup( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Prepare webhooks are registered by waiting a second.""" + freezer.tick(timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index d258986bdaf..aa757486f86 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -2,13 +2,14 @@ from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientResponseError +from freezegun.api import FrozenDateTimeFactory import pytest from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from . import call_webhook, setup_integration +from . import call_webhook, prepare_webhook_setup, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -20,9 +21,11 @@ async def test_binary_sensor( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test binary sensor.""" await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) client = await hass_client_no_auth() diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 1c562182ae7..dd112671945 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -15,16 +15,11 @@ from homeassistant.components.cloud import CloudNotAvailable from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, async_setup from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_WEBHOOK_ID, - EVENT_HOMEASSISTANT_STARTED, -) -from homeassistant.core import CoreState, HomeAssistant +from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import call_webhook, setup_integration +from . import call_webhook, prepare_webhook_setup, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import ( @@ -197,9 +192,12 @@ async def test_webhooks_request_data( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test calling a webhook requests data.""" await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + client = await hass_client_no_auth() assert withings.async_measure_get_meas.call_count == 1 @@ -213,35 +211,6 @@ async def test_webhooks_request_data( assert withings.async_measure_get_meas.call_count == 2 -async def test_delayed_startup( - hass: HomeAssistant, - withings: AsyncMock, - webhook_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, - freezer: FrozenDateTimeFactory, -) -> None: - """Test delayed start up.""" - hass.state = CoreState.not_running - await setup_integration(hass, webhook_config_entry) - - withings.async_notify_subscribe.assert_not_called() - client = await hass_client_no_auth() - - assert withings.async_measure_get_meas.call_count == 1 - await hass.async_block_till_done() - - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - - await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, - client, - ) - assert withings.async_measure_get_meas.call_count == 2 - - @pytest.mark.parametrize( "error", [ @@ -395,7 +364,10 @@ async def test_removing_entry_with_cloud_unavailable( async def test_setup_with_cloud( - hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock + hass: HomeAssistant, + webhook_config_entry: MockConfigEntry, + withings: AsyncMock, + freezer: FrozenDateTimeFactory, ) -> None: """Test if set up with active cloud subscription.""" await mock_cloud(hass) @@ -418,6 +390,8 @@ async def test_setup_with_cloud( "homeassistant.components.withings.webhook_generate_url" ): await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + assert hass.components.cloud.async_active_subscription() is True assert hass.components.cloud.async_is_connected() is True fake_create_cloudhook.assert_called_once() @@ -444,6 +418,7 @@ async def test_setup_without_https( webhook_config_entry: MockConfigEntry, withings: AsyncMock, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -457,6 +432,7 @@ async def test_setup_without_https( ) as mock_async_generate_url: mock_async_generate_url.return_value = "http://example.com" await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) await hass.async_block_till_done() mock_async_generate_url.assert_called_once() @@ -492,6 +468,7 @@ async def test_cloud_disconnect( "homeassistant.components.withings.webhook_generate_url" ): await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) assert hass.components.cloud.async_active_subscription() is True assert hass.components.cloud.async_is_connected() is True @@ -537,9 +514,11 @@ async def test_webhook_post( body: dict[str, Any], expected_code: int, current_request_with_host: None, + freezer: FrozenDateTimeFactory, ) -> None: """Test webhook callback.""" await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index fe640e315a0..44ae10b6a94 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, setup_integration +from . import call_webhook, prepare_webhook_setup, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -96,9 +96,11 @@ async def test_sensor_default_enabled_entities( withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, ) -> None: """Test entities enabled by default.""" await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) entity_registry: EntityRegistry = er.async_get(hass) client = await hass_client_no_auth() From d654c4bc1e4ef76cf6b9666c6beb1f0ac0e9dbdc Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Fri, 6 Oct 2023 18:23:18 +0200 Subject: [PATCH 246/968] Fix device_class.capitalize() in Point (#101440) --- homeassistant/components/point/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 130ea116cc1..9fe63bf1d55 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -277,7 +277,8 @@ class MinutPointEntity(Entity): sw_version=device["firmware"]["installed"], via_device=(DOMAIN, device["home"]), ) - self._attr_name = f"{self._name} {device_class.capitalize()}" + if device_class: + self._attr_name = f"{self._name} {device_class.capitalize()}" def __str__(self): """Return string representation of device.""" From 5d0c8947a174cce7109237571d0ccf5d4ab85e4a Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 6 Oct 2023 18:23:48 +0200 Subject: [PATCH 247/968] Fix ZHA device diagnostics error for unknown unsupported attributes (#101239) * Modify test to account for scenario of unknown unsupported attributes * Add error checking for finding unsupported attributes * Change comment to clarify zigpy misses an attribute def This should make it more clear that it's about an unknown attribute (where zigpy doesn't have an attribute definition). * Increase test coverage This increases test coverage by doing the following: - adding the `IasZone` to our test device, so we have a cluster which actually has some attribute definitions - adding not just an unknown unsupported attribute by id, but also by name - adding a known unsupported attribute by id and by name * Fix diagnostics logic --- homeassistant/components/zha/diagnostics.py | 20 ++++++++++++++------ tests/components/zha/test_diagnostics.py | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 0fa1de5ff0e..ae68e6d5cca 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -139,6 +139,19 @@ def get_endpoint_cluster_attr_data(zha_device: ZHADevice) -> dict: def get_cluster_attr_data(cluster: Cluster) -> dict: """Return cluster attribute data.""" + unsupported_attributes = {} + for u_attr in cluster.unsupported_attributes: + try: + u_attr_def = cluster.find_attribute(u_attr) + unsupported_attributes[f"0x{u_attr_def.id:04x}"] = { + ATTR_ATTRIBUTE_NAME: u_attr_def.name + } + except KeyError: + if isinstance(u_attr, int): + unsupported_attributes[f"0x{u_attr:04x}"] = {} + else: + unsupported_attributes[u_attr] = {} + return { ATTRIBUTES: { f"0x{attr_id:04x}": { @@ -148,10 +161,5 @@ def get_cluster_attr_data(cluster: Cluster) -> dict: for attr_id, attr_def in cluster.attributes.items() if (attr_value := cluster.get(attr_def.name)) is not None }, - UNSUPPORTED_ATTRIBUTES: { - f"0x{cluster.find_attribute(u_attr).id:04x}": { - ATTR_ATTRIBUTE_NAME: cluster.find_attribute(u_attr).name - } - for u_attr in cluster.unsupported_attributes - }, + UNSUPPORTED_ATTRIBUTES: unsupported_attributes, } diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index c13bb36c1c0..1f6a731d0fb 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -44,7 +44,7 @@ def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" endpoints = { 1: { - SIG_EP_INPUT: [security.IasAce.cluster_id], + SIG_EP_INPUT: [security.IasAce.cluster_id, security.IasZone.cluster_id], SIG_EP_OUTPUT: [], SIG_EP_TYPE: zha.DeviceType.IAS_ANCILLARY_CONTROL, SIG_EP_PROFILE: zha.PROFILE_ID, @@ -93,6 +93,22 @@ async def test_diagnostics_for_device( ) -> None: """Test diagnostics for device.""" zha_device: ZHADevice = await zha_device_joined(zigpy_device) + + # add unknown unsupported attribute with id and name + zha_device.device.endpoints[1].in_clusters[ + security.IasAce.cluster_id + ].unsupported_attributes.update({0x1000, "unknown_attribute_name"}) + + # add known unsupported attributes with id and name + zha_device.device.endpoints[1].in_clusters[ + security.IasZone.cluster_id + ].unsupported_attributes.update( + { + security.IasZone.AttributeDefs.num_zone_sensitivity_levels_supported.id, + security.IasZone.AttributeDefs.current_zone_sensitivity_level.name, + } + ) + dev_reg = async_get(hass) device = dev_reg.async_get_device(identifiers={("zha", str(zha_device.ieee))}) assert device From 4e98d39106c71712441ead6cbf07c66f258bd490 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Oct 2023 19:57:43 +0200 Subject: [PATCH 248/968] Use loader.async_suggest_report_issue in async util (#101516) --- homeassistant/util/async_.py | 74 +++++++++++++++++++----------------- tests/util/test_async.py | 30 +++++++-------- 2 files changed, 54 insertions(+), 50 deletions(-) diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index ce1105cff75..bc4cf68bb81 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -5,12 +5,15 @@ from asyncio import Future, Semaphore, gather, get_running_loop from asyncio.events import AbstractEventLoop from collections.abc import Awaitable, Callable import concurrent.futures +from contextlib import suppress import functools import logging import threading from traceback import extract_stack from typing import Any, ParamSpec, TypeVar +from homeassistant.exceptions import HomeAssistantError + _LOGGER = logging.getLogger(__name__) _SHUTDOWN_RUN_CALLBACK_THREADSAFE = "_shutdown_run_callback_threadsafe" @@ -82,6 +85,14 @@ def check_loop( The default advisory message is 'Use `await hass.async_add_executor_job()' Set `advise_msg` to an alternate message if the solution differs. """ + # pylint: disable=import-outside-toplevel + from homeassistant.core import HomeAssistant, async_get_hass + from homeassistant.helpers.frame import ( + MissingIntegrationFrame, + get_integration_frame, + ) + from homeassistant.loader import async_suggest_report_issue + try: get_running_loop() in_loop = True @@ -104,54 +115,47 @@ def check_loop( # stack[-1] is us, stack[-2] is protected_loop_func, stack[-3] is the offender return - for frame in reversed(stack): - for path in ("custom_components/", "homeassistant/components/"): - try: - index = frame.filename.index(path) - found_frame = frame - break - except ValueError: - continue + try: + integration_frame = get_integration_frame() + except MissingIntegrationFrame: + # Did not source from integration? Hard error. + if found_frame is None: + raise RuntimeError( # noqa: TRY200 + f"Detected blocking call to {func.__name__} inside the event loop. " + f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " + "This is causing stability issues. Please create a bug report at " + f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) - if found_frame is not None: - break - - # Did not source from integration? Hard error. - if found_frame is None: - raise RuntimeError( - f"Detected blocking call to {func.__name__} inside the event loop. " - f"{advise_msg or 'Use `await hass.async_add_executor_job()`'}; " - "This is causing stability issues. Please report issue" - ) - - start = index + len(path) - end = found_frame.filename.index("/", start) - - integration = found_frame.filename[start:end] - - if path == "custom_components/": - extra = " to the custom integration author" - else: - extra = "" + hass: HomeAssistant | None = None + with suppress(HomeAssistantError): + hass = async_get_hass() + report_issue = async_suggest_report_issue( + hass, + integration_domain=integration_frame.integration, + module=integration_frame.module, + ) + found_frame = integration_frame.frame _LOGGER.warning( ( - "Detected blocking call to %s inside the event loop. This is causing" - " stability issues. Please report issue%s for %s doing blocking calls at" - " %s, line %s: %s" + "Detected blocking call to %s inside the event loop by %sintegration '%s' " + "at %s, line %s: %s, please %s" ), func.__name__, - extra, - integration, - found_frame.filename[index:], + "custom " if integration_frame.custom_integration else "", + integration_frame.integration, + integration_frame.relative_filename, found_frame.lineno, (found_frame.line or "?").strip(), + report_issue, ) + if strict: raise RuntimeError( "Blocking calls must be done in the executor or a separate thread;" f" {advise_msg or 'Use `await hass.async_add_executor_job()`'}; at" - f" {found_frame.filename[index:]}, line {found_frame.lineno}:" + f" {integration_frame.relative_filename}, line {found_frame.lineno}:" f" {(found_frame.line or '?').strip()}" ) diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 7b0cc916ec7..4945e95d2d7 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -48,7 +48,7 @@ async def test_check_loop_async() -> None: async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects and raises when called from event loop from integration context.""" with pytest.raises(RuntimeError), patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -69,10 +69,10 @@ async def test_check_loop_async_integration(caplog: pytest.LogCaptureFixture) -> ): hasync.check_loop(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop. This is " - "causing stability issues. Please report issue for hue doing blocking calls at " - "homeassistant/components/hue/light.py, line 23: self.light.is_on" - in caplog.text + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) @@ -81,7 +81,7 @@ async def test_check_loop_async_integration_non_strict( ) -> None: """Test check_loop detects when called from event loop from integration context.""" with patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -102,17 +102,17 @@ async def test_check_loop_async_integration_non_strict( ): hasync.check_loop(banned_function, strict=False) assert ( - "Detected blocking call to banned_function inside the event loop. This is " - "causing stability issues. Please report issue for hue doing blocking calls at " - "homeassistant/components/hue/light.py, line 23: self.light.is_on" - in caplog.text + "Detected blocking call to banned_function inside the event loop by integration" + " 'hue' at homeassistant/components/hue/light.py, line 23: self.light.is_on, " + "please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" in caplog.text ) async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None: """Test check_loop detects when called from event loop with custom component context.""" with pytest.raises(RuntimeError), patch( - "homeassistant.util.async_.extract_stack", + "homeassistant.helpers.frame.extract_stack", return_value=[ Mock( filename="/home/paulus/homeassistant/core.py", @@ -133,10 +133,10 @@ async def test_check_loop_async_custom(caplog: pytest.LogCaptureFixture) -> None ): hasync.check_loop(banned_function) assert ( - "Detected blocking call to banned_function inside the event loop. This is" - " causing stability issues. Please report issue to the custom integration" - " author for hue doing blocking calls at custom_components/hue/light.py, line" - " 23: self.light.is_on" + "Detected blocking call to banned_function inside the event loop by custom " + "integration 'hue' at custom_components/hue/light.py, line 23: self.light.is_on" + ", please create a bug report at https://github.com/home-assistant/core/issues?" + "q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+hue%22" ) in caplog.text From ed8a372f4eeecc80aaa1ec2a3265aa4672f28c6d Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Fri, 6 Oct 2023 11:00:04 -0700 Subject: [PATCH 249/968] Auto-fix common key entry issues during WeatherKit config flow (#101504) --- .../components/weatherkit/config_flow.py | 20 ++++++++ .../components/weatherkit/test_config_flow.py | 51 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py index 5762c4ae9b2..657a80547ab 100644 --- a/homeassistant/components/weatherkit/config_flow.py +++ b/homeassistant/components/weatherkit/config_flow.py @@ -66,6 +66,7 @@ class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: try: + user_input[CONF_KEY_PEM] = self._fix_key_input(user_input[CONF_KEY_PEM]) await self._test_config(user_input) except WeatherKitUnsupportedLocationError as exception: LOGGER.error(exception) @@ -104,6 +105,25 @@ class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + def _fix_key_input(self, key_input: str) -> str: + """Fix common user errors with the key input.""" + # OSes may sometimes turn two hyphens (--) into an em dash (—) + key_input = key_input.replace("—", "--") + + # Trim whitespace and line breaks + key_input = key_input.strip() + + # Make sure header and footer are present + header = "-----BEGIN PRIVATE KEY-----" + if not key_input.startswith(header): + key_input = f"{header}\n{key_input}" + + footer = "-----END PRIVATE KEY-----" + if not key_input.endswith(footer): + key_input += f"\n{footer}" + + return key_input + async def _test_config(self, user_input: dict[str, Any]) -> None: """Validate credentials.""" client = WeatherKitApiClient( diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py index 3b6cf76a3d5..9e4d03cbad4 100644 --- a/tests/components/weatherkit/test_config_flow.py +++ b/tests/components/weatherkit/test_config_flow.py @@ -126,3 +126,54 @@ async def test_form_unsupported_location(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY + + +@pytest.mark.parametrize( + ("input_header"), + [ + "-----BEGIN PRIVATE KEY-----\n", + "", + " \n\n-----BEGIN PRIVATE KEY-----\n", + "—---BEGIN PRIVATE KEY-----\n", + ], + ids=["Correct header", "No header", "Leading characters", "Em dash in header"], +) +@pytest.mark.parametrize( + ("input_footer"), + [ + "\n-----END PRIVATE KEY-----", + "", + "\n-----END PRIVATE KEY-----\n\n ", + "\n—---END PRIVATE KEY-----", + ], + ids=["Correct footer", "No footer", "Trailing characters", "Em dash in footer"], +) +async def test_auto_fix_key_input( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + input_header: str, + input_footer: str, +) -> None: + """Test that we fix common user errors in key input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], + ): + user_input = EXAMPLE_USER_INPUT.copy() + user_input[CONF_KEY_PEM] = f"{input_header}whateverkey{input_footer}" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + assert result["data"][CONF_KEY_PEM] == EXAMPLE_CONFIG_DATA[CONF_KEY_PEM] + assert len(mock_setup_entry.mock_calls) == 1 From 9ac5bdc83261b026358bdf0be27f444ef59d4b35 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 6 Oct 2023 20:04:44 +0200 Subject: [PATCH 250/968] Use modern naming for WLED (#100233) --- homeassistant/components/wled/config_flow.py | 6 +-- homeassistant/components/wled/const.py | 4 +- homeassistant/components/wled/coordinator.py | 16 +++---- homeassistant/components/wled/light.py | 46 ++++++++++---------- homeassistant/components/wled/strings.json | 2 +- tests/components/wled/test_config_flow.py | 6 +-- tests/components/wled/test_light.py | 4 +- 7 files changed, 42 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 1dda368a2b0..cbb78545e2b 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -13,7 +13,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT, DOMAIN +from .const import CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT, DOMAIN class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): @@ -136,9 +136,9 @@ class WLEDOptionsFlowHandler(OptionsFlow): data_schema=vol.Schema( { vol.Optional( - CONF_KEEP_MASTER_LIGHT, + CONF_KEEP_MAIN_LIGHT, default=self.config_entry.options.get( - CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT + CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ), ): bool, } diff --git a/homeassistant/components/wled/const.py b/homeassistant/components/wled/const.py index 40f831772bc..cee9984a3f6 100644 --- a/homeassistant/components/wled/const.py +++ b/homeassistant/components/wled/const.py @@ -9,8 +9,8 @@ LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = timedelta(seconds=10) # Options -CONF_KEEP_MASTER_LIGHT = "keep_master_light" -DEFAULT_KEEP_MASTER_LIGHT = False +CONF_KEEP_MAIN_LIGHT = "keep_master_light" +DEFAULT_KEEP_MAIN_LIGHT = False # Attributes ATTR_COLOR_PRIMARY = "color_primary" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 6f3bae03bfa..6bbcb1747f0 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -10,8 +10,8 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - CONF_KEEP_MASTER_LIGHT, - DEFAULT_KEEP_MASTER_LIGHT, + CONF_KEEP_MAIN_LIGHT, + DEFAULT_KEEP_MAIN_LIGHT, DOMAIN, LOGGER, SCAN_INTERVAL, @@ -21,7 +21,7 @@ from .const import ( class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): """Class to manage fetching WLED data from single endpoint.""" - keep_master_light: bool + keep_main_light: bool config_entry: ConfigEntry def __init__( @@ -31,8 +31,8 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): entry: ConfigEntry, ) -> None: """Initialize global WLED data updater.""" - self.keep_master_light = entry.options.get( - CONF_KEEP_MASTER_LIGHT, DEFAULT_KEEP_MASTER_LIGHT + self.keep_main_light = entry.options.get( + CONF_KEEP_MAIN_LIGHT, DEFAULT_KEEP_MAIN_LIGHT ) self.wled = WLED(entry.data[CONF_HOST], session=async_get_clientsession(hass)) self.unsub: CALLBACK_TYPE | None = None @@ -45,9 +45,9 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): ) @property - def has_master_light(self) -> bool: - """Return if the coordinated device has a master light.""" - return self.keep_master_light or ( + def has_main_light(self) -> bool: + """Return if the coordinated device has a main light.""" + return self.keep_main_light or ( self.data is not None and len(self.data.state.segments) > 1 ) diff --git a/homeassistant/components/wled/light.py b/homeassistant/components/wled/light.py index 6675118e565..b793654c886 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -33,8 +33,8 @@ async def async_setup_entry( ) -> None: """Set up WLED light based on a config entry.""" coordinator: WLEDDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - if coordinator.keep_master_light: - async_add_entities([WLEDMasterLight(coordinator=coordinator)]) + if coordinator.keep_main_light: + async_add_entities([WLEDMainLight(coordinator=coordinator)]) update_segments = partial( async_update_segments, @@ -47,8 +47,8 @@ async def async_setup_entry( update_segments() -class WLEDMasterLight(WLEDEntity, LightEntity): - """Defines a WLED master light.""" +class WLEDMainLight(WLEDEntity, LightEntity): + """Defines a WLED main light.""" _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:led-strip-variant" @@ -57,7 +57,7 @@ class WLEDMasterLight(WLEDEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: - """Initialize WLED master light.""" + """Initialize WLED main light.""" super().__init__(coordinator=coordinator) self._attr_unique_id = coordinator.data.info.mac_address @@ -73,8 +73,8 @@ class WLEDMasterLight(WLEDEntity, LightEntity): @property def available(self) -> bool: - """Return if this master light is available or not.""" - return self.coordinator.has_master_light and super().available + """Return if this main light is available or not.""" + return self.coordinator.has_main_light and super().available @wled_exception_handler async def async_turn_off(self, **kwargs: Any) -> None: @@ -167,8 +167,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): state = self.coordinator.data.state # If this is the one and only segment, calculate brightness based - # on the master and segment brightness - if not self.coordinator.has_master_light: + # on the main and segment brightness + if not self.coordinator.has_main_light: return int( (state.segments[self._segment].brightness * state.brightness) / 255 ) @@ -185,9 +185,9 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): """Return the state of the light.""" state = self.coordinator.data.state - # If there is no master, we take the master state into account + # If there is no main, we take the main state into account # on the segment level. - if not self.coordinator.has_master_light and not state.on: + if not self.coordinator.has_main_light and not state.on: return False return bool(state.segments[self._segment].on) @@ -200,8 +200,8 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): # WLED uses 100ms per unit, so 10 = 1 second. transition = round(kwargs[ATTR_TRANSITION] * 10) - # If there is no master control, and only 1 segment, handle the master - if not self.coordinator.has_master_light: + # If there is no main control, and only 1 segment, handle the main + if not self.coordinator.has_main_light: await self.coordinator.wled.master(on=False, transition=transition) return @@ -233,19 +233,19 @@ class WLEDSegmentLight(WLEDEntity, LightEntity): if ATTR_EFFECT in kwargs: data[ATTR_EFFECT] = kwargs[ATTR_EFFECT] - # If there is no master control, and only 1 segment, handle the master - if not self.coordinator.has_master_light: - master_data = {ATTR_ON: True} + # If there is no main control, and only 1 segment, handle the main + if not self.coordinator.has_main_light: + main_data = {ATTR_ON: True} if ATTR_BRIGHTNESS in data: - master_data[ATTR_BRIGHTNESS] = data[ATTR_BRIGHTNESS] + main_data[ATTR_BRIGHTNESS] = data[ATTR_BRIGHTNESS] data[ATTR_BRIGHTNESS] = 255 if ATTR_TRANSITION in data: - master_data[ATTR_TRANSITION] = data[ATTR_TRANSITION] + main_data[ATTR_TRANSITION] = data[ATTR_TRANSITION] del data[ATTR_TRANSITION] await self.coordinator.wled.segment(**data) - await self.coordinator.wled.master(**master_data) + await self.coordinator.wled.master(**main_data) return await self.coordinator.wled.segment(**data) @@ -259,13 +259,13 @@ def async_update_segments( ) -> None: """Update segments.""" segment_ids = {light.segment_id for light in coordinator.data.state.segments} - new_entities: list[WLEDMasterLight | WLEDSegmentLight] = [] + new_entities: list[WLEDMainLight | WLEDSegmentLight] = [] - # More than 1 segment now? No master? Add master controls - if not coordinator.keep_master_light and ( + # More than 1 segment now? No main? Add main controls + if not coordinator.keep_main_light and ( len(current_ids) < 2 and len(segment_ids) > 1 ): - new_entities.append(WLEDMasterLight(coordinator)) + new_entities.append(WLEDMainLight(coordinator)) # Process new segments, add them to Home Assistant for segment_id in segment_ids - current_ids: diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 5791732dfbe..61b9cc450fe 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -26,7 +26,7 @@ "step": { "init": { "data": { - "keep_master_light": "Keep master light, even with 1 LED segment." + "keep_master_light": "Keep main light, even with 1 LED segment." } } } diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index de01510adb3..949916aaccc 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from wled import WLEDConnectionError from homeassistant.components import zeroconf -from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, DOMAIN +from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, DOMAIN from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant @@ -271,10 +271,10 @@ async def test_options_flow( result2 = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_KEEP_MASTER_LIGHT: True}, + user_input={CONF_KEEP_MAIN_LIGHT: True}, ) assert result2.get("type") == FlowResultType.CREATE_ENTRY assert result2.get("data") == { - CONF_KEEP_MASTER_LIGHT: True, + CONF_KEEP_MAIN_LIGHT: True, } diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index ab8330293ba..2594c228eda 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -15,7 +15,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, DOMAIN as LIGHT_DOMAIN, ) -from homeassistant.components.wled.const import CONF_KEEP_MASTER_LIGHT, SCAN_INTERVAL +from homeassistant.components.wled.const import CONF_KEEP_MAIN_LIGHT, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, @@ -355,7 +355,7 @@ async def test_single_segment_with_keep_main_light( assert not hass.states.get("light.wled_rgb_light_main") hass.config_entries.async_update_entry( - init_integration, options={CONF_KEEP_MASTER_LIGHT: True} + init_integration, options={CONF_KEEP_MAIN_LIGHT: True} ) await hass.async_block_till_done() From 475cb7719b773dbf28097e5247946947ab9c66ea Mon Sep 17 00:00:00 2001 From: Justin Grover Date: Fri, 6 Oct 2023 12:15:40 -0600 Subject: [PATCH 251/968] Add unique ID for generic hygrostat (#101503) --- .../components/generic_hygrostat/__init__.py | 4 ++- .../generic_hygrostat/humidifier.py | 5 ++++ .../generic_hygrostat/test_humidifier.py | 28 +++++++++++++++++++ .../generic_thermostat/test_climate.py | 2 +- 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/generic_hygrostat/__init__.py b/homeassistant/components/generic_hygrostat/__init__.py index 0821e8798c7..585d0aa1fe3 100644 --- a/homeassistant/components/generic_hygrostat/__init__.py +++ b/homeassistant/components/generic_hygrostat/__init__.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.components.humidifier import HumidifierDeviceClass -from homeassistant.const import CONF_NAME, Platform +from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType @@ -24,6 +24,7 @@ CONF_AWAY_HUMIDITY = "away_humidity" CONF_AWAY_FIXED = "away_fixed" CONF_STALE_DURATION = "sensor_stale_duration" + DEFAULT_TOLERANCE = 3 DEFAULT_NAME = "Generic Hygrostat" @@ -48,6 +49,7 @@ HYGROSTAT_SCHEMA = vol.Schema( vol.Optional(CONF_STALE_DURATION): vol.All( cv.time_period, cv.positive_timedelta ), + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 959b0a8e8df..3bdecbfa997 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME, + CONF_UNIQUE_ID, EVENT_HOMEASSISTANT_START, SERVICE_TURN_OFF, SERVICE_TURN_ON, @@ -86,6 +87,7 @@ async def async_setup_platform( initial_state = config.get(CONF_INITIAL_STATE) away_humidity = config.get(CONF_AWAY_HUMIDITY) away_fixed = config.get(CONF_AWAY_FIXED) + unique_id = config.get(CONF_UNIQUE_ID) async_add_entities( [ @@ -105,6 +107,7 @@ async def async_setup_platform( away_humidity, away_fixed, sensor_stale_duration, + unique_id, ) ] ) @@ -132,6 +135,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): away_humidity, away_fixed, sensor_stale_duration, + unique_id, ): """Initialize the hygrostat.""" self._name = name @@ -160,6 +164,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): if not self._device_class: self._device_class = HumidifierDeviceClass.HUMIDIFIER self._attr_action = HumidifierAction.IDLE + self._attr_unique_id = unique_id async def async_added_to_hass(self): """Run when entity about to be added.""" diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index e3fb26ffe22..bd97a683989 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -31,6 +31,7 @@ from homeassistant.core import ( State, callback, ) +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -169,6 +170,33 @@ async def test_humidifier_switch( assert hass.states.get(ENTITY).attributes.get("action") == "humidifying" +async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: + """Test setting a unique ID.""" + unique_id = "some_unique_id" + _setup_sensor(hass, 18) + await _setup_switch(hass, True) + assert await async_setup_component( + hass, + DOMAIN, + { + "humidifier": { + "platform": "generic_hygrostat", + "name": "test", + "humidifier": ENT_SWITCH, + "target_sensor": ENT_SENSOR, + "unique_id": unique_id, + } + }, + ) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get(ENTITY) + assert entry + assert entry.unique_id == unique_id + + def _setup_sensor(hass, humidity): """Set up the test sensor.""" hass.states.async_set(ENT_SENSOR, humidity) diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 4eb2e3ce711..2a406ddbd79 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -174,7 +174,7 @@ async def test_heater_switch( async def test_unique_id(hass: HomeAssistant, setup_comp_1) -> None: - """Test heater switching input_boolean.""" + """Test setting a unique ID.""" unique_id = "some_unique_id" _setup_sensor(hass, 18) _setup_switch(hass, True) From bb7ddddd4cabda922bb65b4aac81f0ac5c90d3a1 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 6 Oct 2023 20:26:52 +0200 Subject: [PATCH 252/968] Use snapshot assertion for lametric diagnostics test (#99164) --- .../lametric/snapshots/test_diagnostics.ambr | 48 +++++++++++++++++++ tests/components/lametric/test_diagnostics.py | 48 +++---------------- 2 files changed, 54 insertions(+), 42 deletions(-) create mode 100644 tests/components/lametric/snapshots/test_diagnostics.ambr diff --git a/tests/components/lametric/snapshots/test_diagnostics.ambr b/tests/components/lametric/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..cadd0e37566 --- /dev/null +++ b/tests/components/lametric/snapshots/test_diagnostics.ambr @@ -0,0 +1,48 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'audio': dict({ + 'volume': 100, + 'volume_limit': dict({ + 'range_max': 100, + 'range_min': 0, + }), + 'volume_range': dict({ + 'range_max': 100, + 'range_min': 0, + }), + }), + 'bluetooth': dict({ + 'active': False, + 'address': 'AA:BB:CC:DD:EE:FF', + 'available': True, + 'discoverable': True, + 'name': '**REDACTED**', + 'pairable': True, + }), + 'device_id': '**REDACTED**', + 'display': dict({ + 'brightness': 100, + 'brightness_mode': 'auto', + 'display_type': 'mixed', + 'height': 8, + 'width': 37, + }), + 'mode': 'auto', + 'model': 'LM 37X8', + 'name': '**REDACTED**', + 'os_version': '2.2.2', + 'serial_number': '**REDACTED**', + 'wifi': dict({ + 'active': True, + 'available': True, + 'encryption': 'WPA', + 'ip': '127.0.0.1', + 'mac': 'AA:BB:CC:DD:EE:FF', + 'mode': 'dhcp', + 'netmask': '255.255.255.0', + 'rssi': 21, + 'ssid': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/lametric/test_diagnostics.py b/tests/components/lametric/test_diagnostics.py index e36d38b91e3..333985f71a0 100644 --- a/tests/components/lametric/test_diagnostics.py +++ b/tests/components/lametric/test_diagnostics.py @@ -1,6 +1,6 @@ """Tests for the diagnostics data provided by the LaMetric integration.""" +from syrupy import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,46 +12,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "device_id": REDACTED, - "name": REDACTED, - "serial_number": REDACTED, - "os_version": "2.2.2", - "mode": "auto", - "model": "LM 37X8", - "audio": { - "volume": 100, - "volume_range": {"range_min": 0, "range_max": 100}, - "volume_limit": {"range_min": 0, "range_max": 100}, - }, - "bluetooth": { - "available": True, - "name": REDACTED, - "active": False, - "discoverable": True, - "pairable": True, - "address": "AA:BB:CC:DD:EE:FF", - }, - "display": { - "brightness": 100, - "brightness_mode": "auto", - "width": 37, - "height": 8, - "display_type": "mixed", - }, - "wifi": { - "active": True, - "mac": "AA:BB:CC:DD:EE:FF", - "available": True, - "encryption": "WPA", - "ssid": REDACTED, - "ip": "127.0.0.1", - "mode": "dhcp", - "netmask": "255.255.255.0", - "rssi": 21, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 73debba60c790d2b4bd031b6017de65ae4c128cf Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 6 Oct 2023 20:40:09 +0200 Subject: [PATCH 253/968] Update home-assistant/wheels to 2023.10.2 (#101549) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 52455b616ef..75fcee53cf5 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.1 + uses: home-assistant/wheels@2023.10.2 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -190,7 +190,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.1 + uses: home-assistant/wheels@2023.10.2 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -205,7 +205,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.1 + uses: home-assistant/wheels@2023.10.2 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.1 + uses: home-assistant/wheels@2023.10.2 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -233,7 +233,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.1 + uses: home-assistant/wheels@2023.10.2 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 8877cafe0c430e1b4a68a3a6bbdc8aff10778d89 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 6 Oct 2023 21:04:23 +0200 Subject: [PATCH 254/968] Update pydrawise to 2023.10.0 (#101548) --- homeassistant/components/hydrawise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index eea4a0e2ebf..4e73a2ba64c 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2023.8.0"] + "requirements": ["pydrawise==2023.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8aa312e25ce..ef817124ad8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1662,7 +1662,7 @@ pydiscovergy==2.0.3 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2023.8.0 +pydrawise==2023.10.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c9ac642a06..5803986ff9d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1250,7 +1250,7 @@ pydexcom==0.2.3 pydiscovergy==2.0.3 # homeassistant.components.hydrawise -pydrawise==2023.8.0 +pydrawise==2023.10.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 207adaf9cd26442fa548fd1a3539c8b6845efff4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 6 Oct 2023 21:25:04 +0200 Subject: [PATCH 255/968] Make AugustOperatorSensor a RestoreSensor (#98526) --- homeassistant/components/august/sensor.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 75e8cd8984c..7f6e0c51995 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -12,6 +12,7 @@ from yalexs.keypad import KeypadDetail from yalexs.lock import Lock, LockDetail from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -27,7 +28,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.helpers.restore_state import RestoreEntity from . import AugustData from .const import ( @@ -174,8 +174,7 @@ async def _async_migrate_old_unique_ids(hass, devices): registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): +class AugustOperatorSensor(AugustEntityMixin, RestoreSensor): """Representation of an August lock operation sensor.""" _attr_translation_key = "operator" @@ -247,10 +246,15 @@ class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): await super().async_added_to_hass() last_state = await self.async_get_last_state() - if not last_state or last_state.state == STATE_UNAVAILABLE: + last_sensor_state = await self.async_get_last_sensor_data() + if ( + not last_state + or not last_sensor_state + or last_state.state == STATE_UNAVAILABLE + ): return - self._attr_native_value = last_state.state + self._attr_native_value = last_sensor_state.native_value if ATTR_ENTITY_PICTURE in last_state.attributes: self._attr_entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] if ATTR_OPERATION_REMOTE in last_state.attributes: From c70c2f4be428bd15eeda50482a6513f1e57f35c5 Mon Sep 17 00:00:00 2001 From: tronikos Date: Fri, 6 Oct 2023 12:27:31 -0700 Subject: [PATCH 256/968] Allow derivative/integration on input_number via the UI (#101439) --- homeassistant/components/derivative/config_flow.py | 3 ++- homeassistant/components/integration/config_flow.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index e7c7a44117a..726d7616aff 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_NAME, CONF_SOURCE, UnitOfTime from homeassistant.helpers import selector @@ -66,7 +67,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN), + selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]), ), } ).extend(OPTIONS_SCHEMA.schema) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 65840383926..90fc7195cec 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -6,6 +6,7 @@ from typing import Any, cast import voluptuous as vol +from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_METHOD, CONF_NAME, UnitOfTime from homeassistant.helpers import selector @@ -58,7 +59,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_SOURCE_SENSOR): selector.EntitySelector( - selector.EntitySelectorConfig(domain=SENSOR_DOMAIN) + selector.EntitySelectorConfig(domain=[INPUT_NUMBER_DOMAIN, SENSOR_DOMAIN]) ), vol.Required(CONF_METHOD, default=METHOD_TRAPEZOIDAL): selector.SelectSelector( selector.SelectSelectorConfig( From 43c1769004924a79f17eaa3925edd8f8828782e8 Mon Sep 17 00:00:00 2001 From: Tudor Sandu Date: Fri, 6 Oct 2023 22:32:06 +0300 Subject: [PATCH 257/968] Use walrus assignment i demo climate `climate.set_temperature` (#101248) --- homeassistant/components/demo/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py index b0a53909a46..1e585b12acd 100644 --- a/homeassistant/components/demo/climate.py +++ b/homeassistant/components/demo/climate.py @@ -259,8 +259,8 @@ class DemoClimate(ClimateEntity): ): self._target_temperature_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) self._target_temperature_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - if kwargs.get(ATTR_HVAC_MODE) is not None: - self._hvac_mode = HVACMode(str(kwargs.get(ATTR_HVAC_MODE))) + if (hvac_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + self._hvac_mode = hvac_mode self.async_write_ha_state() async def async_set_humidity(self, humidity: int) -> None: From c8eb62cf4e0933a8695d17811f5b4a350bd867a3 Mon Sep 17 00:00:00 2001 From: Michael Thingnes Date: Sat, 7 Oct 2023 08:38:05 +1300 Subject: [PATCH 258/968] Remove thimic as metno code owner (#101553) --- CODEOWNERS | 4 ++-- homeassistant/components/met/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 53a96111f88..b63526733b3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -757,8 +757,8 @@ build.json @home-assistant/supervisor /tests/components/melissa/ @kennedyshead /homeassistant/components/melnor/ @vanstinator /tests/components/melnor/ @vanstinator -/homeassistant/components/met/ @danielhiversen @thimic -/tests/components/met/ @danielhiversen @thimic +/homeassistant/components/met/ @danielhiversen +/tests/components/met/ @danielhiversen /homeassistant/components/met_eireann/ @DylanGore /tests/components/met_eireann/ @DylanGore /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index d6466bb64c4..a3190109cac 100644 --- a/homeassistant/components/met/manifest.json +++ b/homeassistant/components/met/manifest.json @@ -1,7 +1,7 @@ { "domain": "met", "name": "Meteorologisk institutt (Met.no)", - "codeowners": ["@danielhiversen", "@thimic"], + "codeowners": ["@danielhiversen"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/met", "iot_class": "cloud_polling", From e68627af7ffef43911da129d22038c2ad779f262 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Fri, 6 Oct 2023 20:42:19 +0100 Subject: [PATCH 259/968] Bump sphinx to 7.2.6 for docs generation (#101220) --- requirements_docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_docs.txt b/requirements_docs.txt index 17b38d6ebc3..ef700013d1d 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==2.4.4 +Sphinx==7.2.6 sphinx-autodoc-typehints==1.10.3 sphinx-autodoc-annotation==1.0.post1 \ No newline at end of file From 617ce994b4eab35e8b99b1903b0d60952723db5b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 6 Oct 2023 22:05:01 +0200 Subject: [PATCH 260/968] Update home-assistant/wheels to 2023.10.3 (#101551) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 75fcee53cf5..b3ae794b8fb 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.2 + uses: home-assistant/wheels@2023.10.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -190,7 +190,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.2 + uses: home-assistant/wheels@2023.10.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -205,7 +205,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.2 + uses: home-assistant/wheels@2023.10.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.2 + uses: home-assistant/wheels@2023.10.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -233,7 +233,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.2 + uses: home-assistant/wheels@2023.10.3 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From da9c42d4579a351e087999acb0d279547fd075d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 6 Oct 2023 12:31:12 -1000 Subject: [PATCH 261/968] Fix failing august test (#101560) --- tests/components/august/test_sensor.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index d0da8ce6d53..ae7d46dcb22 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -331,15 +331,12 @@ async def test_restored_state( # Home assistant is not running yet hass.state = CoreState.not_running - last_reset = "2023-09-22T00:00:00.000000+00:00" mock_restore_cache_with_extra_data( hass, [ ( fake_state, - { - "last_reset": last_reset, - }, + {"native_value": "Tag Unlock", "native_unit_of_measurement": None}, ) ], ) From 0221207b0e989385c321d8bcd70ea6a0fcd4a101 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Sat, 7 Oct 2023 10:10:07 +0200 Subject: [PATCH 262/968] Update pyfronius to 0.7.2 (#101571) --- homeassistant/components/fronius/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index ecf3f81b380..bbe0f452bea 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -12,5 +12,5 @@ "iot_class": "local_polling", "loggers": ["pyfronius"], "quality_scale": "platinum", - "requirements": ["PyFronius==0.7.1"] + "requirements": ["PyFronius==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ef817124ad8..07a9c9db360 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -64,7 +64,7 @@ PyFlick==0.0.2 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.1 +PyFronius==0.7.2 # homeassistant.components.mvglive PyMVGLive==1.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5803986ff9d..93a58aeb0f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -57,7 +57,7 @@ PyFlick==0.0.2 PyFlume==0.6.5 # homeassistant.components.fronius -PyFronius==0.7.1 +PyFronius==0.7.2 # homeassistant.components.met_eireann PyMetEireann==2021.8.0 From ba5aa7759d1e0d40f5e6b2e432816dcb6bb416fc Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 7 Oct 2023 11:05:48 +0200 Subject: [PATCH 263/968] Update ha-philipsjs to 3.1.1 (#101574) Update philips to 3.1.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 46b1340a28d..4751e85d378 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.0"] + "requirements": ["ha-philipsjs==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 07a9c9db360..68af87927d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -955,7 +955,7 @@ ha-ffmpeg==3.1.0 ha-iotawattpy==0.1.1 # homeassistant.components.philips_js -ha-philipsjs==3.1.0 +ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 93a58aeb0f6..8d3e78b3b5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -756,7 +756,7 @@ ha-ffmpeg==3.1.0 ha-iotawattpy==0.1.1 # homeassistant.components.philips_js -ha-philipsjs==3.1.0 +ha-philipsjs==3.1.1 # homeassistant.components.habitica habitipy==0.2.0 From 5ae45e398eb7e8a963b4d5824f40d76c915e0afe Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 7 Oct 2023 11:39:36 +0200 Subject: [PATCH 264/968] Move wallbox base entity to its own file (#101576) --- homeassistant/components/wallbox/__init__.py | 36 +----------------- homeassistant/components/wallbox/entity.py | 40 ++++++++++++++++++++ homeassistant/components/wallbox/lock.py | 3 +- homeassistant/components/wallbox/number.py | 3 +- homeassistant/components/wallbox/sensor.py | 3 +- homeassistant/components/wallbox/switch.py | 3 +- 6 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/wallbox/entity.py diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 4db217d0a54..dde7e1a5181 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -13,24 +13,14 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CHARGER_CURRENCY_KEY, - CHARGER_CURRENT_VERSION_KEY, CHARGER_DATA_KEY, CHARGER_ENERGY_PRICE_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_NAME_KEY, - CHARGER_PART_NUMBER_KEY, - CHARGER_SERIAL_NUMBER_KEY, - CHARGER_SOFTWARE_KEY, CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, @@ -241,27 +231,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class InvalidAuth(HomeAssistantError): """Error to indicate there is invalid auth.""" - - -class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): - """Defines a base Wallbox entity.""" - - _attr_has_entity_name = True - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this Wallbox device.""" - return DeviceInfo( - identifiers={ - ( - DOMAIN, - self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], - ) - }, - name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", - manufacturer="Wallbox", - model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], - sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ - CHARGER_CURRENT_VERSION_KEY - ], - ) diff --git a/homeassistant/components/wallbox/entity.py b/homeassistant/components/wallbox/entity.py new file mode 100644 index 00000000000..c9d12643768 --- /dev/null +++ b/homeassistant/components/wallbox/entity.py @@ -0,0 +1,40 @@ +"""Base entity for the wallbox integration.""" +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import WallboxCoordinator +from .const import ( + CHARGER_CURRENT_VERSION_KEY, + CHARGER_DATA_KEY, + CHARGER_NAME_KEY, + CHARGER_PART_NUMBER_KEY, + CHARGER_SERIAL_NUMBER_KEY, + CHARGER_SOFTWARE_KEY, + DOMAIN, +) + + +class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): + """Defines a base Wallbox entity.""" + + _attr_has_entity_name = True + + @property + def device_info(self) -> DeviceInfo: + """Return device information about this Wallbox device.""" + return DeviceInfo( + identifiers={ + ( + DOMAIN, + self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY], + ) + }, + name=f"Wallbox {self.coordinator.data[CHARGER_NAME_KEY]}", + manufacturer="Wallbox", + model=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY], + sw_version=self.coordinator.data[CHARGER_DATA_KEY][CHARGER_SOFTWARE_KEY][ + CHARGER_CURRENT_VERSION_KEY + ], + ) diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 04a587ae34d..1a2364880f1 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -9,13 +9,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator, WallboxEntity +from . import InvalidAuth, WallboxCoordinator from .const import ( CHARGER_DATA_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { CHARGER_LOCKED_UNLOCKED_KEY: LockEntityDescription( diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index b8ce331146d..cea7bd5ce81 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator, WallboxEntity +from . import InvalidAuth, WallboxCoordinator from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, CHARGER_DATA_KEY, @@ -23,6 +23,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .entity import WallboxEntity @dataclass diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 56d9e0be735..7a08bdd7c02 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WallboxCoordinator, WallboxEntity +from . import WallboxCoordinator from .const import ( CHARGER_ADDED_DISCHARGED_ENERGY_KEY, CHARGER_ADDED_ENERGY_KEY, @@ -43,6 +43,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, DOMAIN, ) +from .entity import WallboxEntity CHARGER_STATION = "station" UPDATE_INTERVAL = 30 diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index b101ffe1c09..f59e64a516f 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WallboxCoordinator, WallboxEntity +from . import WallboxCoordinator from .const import ( CHARGER_DATA_KEY, CHARGER_PAUSE_RESUME_KEY, @@ -17,6 +17,7 @@ from .const import ( DOMAIN, ChargerStatus, ) +from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { CHARGER_PAUSE_RESUME_KEY: SwitchEntityDescription( From e25cf7cbab969473349ea006367beedbd704d269 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 7 Oct 2023 11:56:59 +0200 Subject: [PATCH 265/968] Move wallbox coordinator to its own file (#101577) --- homeassistant/components/wallbox/__init__.py | 188 +----------------- .../components/wallbox/config_flow.py | 2 +- homeassistant/components/wallbox/const.py | 35 ++++ .../components/wallbox/coordinator.py | 159 +++++++++++++++ homeassistant/components/wallbox/entity.py | 2 +- homeassistant/components/wallbox/lock.py | 2 +- homeassistant/components/wallbox/number.py | 2 +- homeassistant/components/wallbox/sensor.py | 2 +- homeassistant/components/wallbox/switch.py | 2 +- tests/components/wallbox/test_init.py | 5 +- tests/components/wallbox/test_lock.py | 2 +- tests/components/wallbox/test_number.py | 2 +- tests/components/wallbox/test_switch.py | 2 +- 13 files changed, 210 insertions(+), 195 deletions(-) create mode 100644 homeassistant/components/wallbox/coordinator.py diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index dde7e1a5181..8194a3ea262 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -1,195 +1,17 @@ """The Wallbox integration.""" from __future__ import annotations -from datetime import timedelta -from http import HTTPStatus -import logging -from typing import Any - -import requests from wallbox import Wallbox from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.exceptions import ConfigEntryAuthFailed -from .const import ( - CHARGER_CURRENCY_KEY, - CHARGER_DATA_KEY, - CHARGER_ENERGY_PRICE_KEY, - CHARGER_LOCKED_UNLOCKED_KEY, - CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_STATUS_DESCRIPTION_KEY, - CHARGER_STATUS_ID_KEY, - CODE_KEY, - CONF_STATION, - DOMAIN, - ChargerStatus, -) - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_STATION, DOMAIN, UPDATE_INTERVAL +from .coordinator import InvalidAuth, WallboxCoordinator PLATFORMS = [Platform.SENSOR, Platform.NUMBER, Platform.LOCK, Platform.SWITCH] -UPDATE_INTERVAL = 30 - -# Translation of StatusId based on Wallbox portal code: -# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js -CHARGER_STATUS: dict[int, ChargerStatus] = { - 0: ChargerStatus.DISCONNECTED, - 14: ChargerStatus.ERROR, - 15: ChargerStatus.ERROR, - 161: ChargerStatus.READY, - 162: ChargerStatus.READY, - 163: ChargerStatus.DISCONNECTED, - 164: ChargerStatus.WAITING, - 165: ChargerStatus.LOCKED, - 166: ChargerStatus.UPDATING, - 177: ChargerStatus.SCHEDULED, - 178: ChargerStatus.PAUSED, - 179: ChargerStatus.SCHEDULED, - 180: ChargerStatus.WAITING_FOR_CAR, - 181: ChargerStatus.WAITING_FOR_CAR, - 182: ChargerStatus.PAUSED, - 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 187: ChargerStatus.WAITING_MID_FAILED, - 188: ChargerStatus.WAITING_MID_SAFETY, - 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, - 193: ChargerStatus.CHARGING, - 194: ChargerStatus.CHARGING, - 195: ChargerStatus.CHARGING, - 196: ChargerStatus.DISCHARGING, - 209: ChargerStatus.LOCKED, - 210: ChargerStatus.LOCKED_CAR_CONNECTED, -} - - -class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): - """Wallbox Coordinator class.""" - - def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: - """Initialize.""" - self._station = station - self._wallbox = wallbox - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=UPDATE_INTERVAL), - ) - - def _authenticate(self) -> None: - """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: - raise ConfigEntryAuthFailed from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - def _validate(self) -> None: - """Authenticate using Wallbox API.""" - try: - self._wallbox.authenticate() - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_validate_input(self) -> None: - """Get new sensor data for Wallbox component.""" - await self.hass.async_add_executor_job(self._validate) - - def _get_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component.""" - try: - self._authenticate() - data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) - data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_MAX_CHARGING_CURRENT_KEY - ] - data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_LOCKED_UNLOCKED_KEY - ] - data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ - CHARGER_ENERGY_PRICE_KEY - ] - data[ - CHARGER_CURRENCY_KEY - ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" - - data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( - data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN - ) - return data - except ( - ConnectionError, - requests.exceptions.HTTPError, - ) as wallbox_connection_error: - raise UpdateFailed from wallbox_connection_error - - async def _async_update_data(self) -> dict[str, Any]: - """Get new sensor data for Wallbox component.""" - return await self.hass.async_add_executor_job(self._get_data) - - def _set_charging_current(self, charging_current: float) -> None: - """Set maximum charging current for Wallbox.""" - try: - self._authenticate() - self._wallbox.setMaxChargingCurrent(self._station, charging_current) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_set_charging_current(self, charging_current: float) -> None: - """Set maximum charging current for Wallbox.""" - await self.hass.async_add_executor_job( - self._set_charging_current, charging_current - ) - await self.async_request_refresh() - - def _set_lock_unlock(self, lock: bool) -> None: - """Set wallbox to locked or unlocked.""" - try: - self._authenticate() - if lock: - self._wallbox.lockCharger(self._station) - else: - self._wallbox.unlockCharger(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_set_lock_unlock(self, lock: bool) -> None: - """Set wallbox to locked or unlocked.""" - await self.hass.async_add_executor_job(self._set_lock_unlock, lock) - await self.async_request_refresh() - - def _pause_charger(self, pause: bool) -> None: - """Set wallbox to pause or resume.""" - try: - self._authenticate() - if pause: - self._wallbox.pauseChargingSession(self._station) - else: - self._wallbox.resumeChargingSession(self._station) - except requests.exceptions.HTTPError as wallbox_connection_error: - if wallbox_connection_error.response.status_code == 403: - raise InvalidAuth from wallbox_connection_error - raise ConnectionError from wallbox_connection_error - - async def async_pause_charger(self, pause: bool) -> None: - """Set wallbox to pause or resume.""" - await self.hass.async_add_executor_job(self._pause_charger, pause) - await self.async_request_refresh() async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -227,7 +49,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class InvalidAuth(HomeAssistantError): - """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index 85f5d02ba99..0f3782958d3 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -11,8 +11,8 @@ from homeassistant import config_entries, core from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from . import InvalidAuth, WallboxCoordinator from .const import CONF_STATION, DOMAIN +from .coordinator import InvalidAuth, WallboxCoordinator COMPONENT_DOMAIN = DOMAIN diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 9bab8232dab..cd3f8a764d0 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -2,6 +2,7 @@ from enum import StrEnum DOMAIN = "wallbox" +UPDATE_INTERVAL = 30 BIDIRECTIONAL_MODEL_PREFIXES = ["QSX"] @@ -55,3 +56,37 @@ class ChargerStatus(StrEnum): WAITING_MID_SAFETY = "Waiting MID safety margin exceeded" WAITING_IN_QUEUE_ECO_SMART = "Waiting in queue by Eco-Smart" UNKNOWN = "Unknown" + + +# Translation of StatusId based on Wallbox portal code: +# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js +CHARGER_STATUS: dict[int, ChargerStatus] = { + 0: ChargerStatus.DISCONNECTED, + 14: ChargerStatus.ERROR, + 15: ChargerStatus.ERROR, + 161: ChargerStatus.READY, + 162: ChargerStatus.READY, + 163: ChargerStatus.DISCONNECTED, + 164: ChargerStatus.WAITING, + 165: ChargerStatus.LOCKED, + 166: ChargerStatus.UPDATING, + 177: ChargerStatus.SCHEDULED, + 178: ChargerStatus.PAUSED, + 179: ChargerStatus.SCHEDULED, + 180: ChargerStatus.WAITING_FOR_CAR, + 181: ChargerStatus.WAITING_FOR_CAR, + 182: ChargerStatus.PAUSED, + 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 187: ChargerStatus.WAITING_MID_FAILED, + 188: ChargerStatus.WAITING_MID_SAFETY, + 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, + 193: ChargerStatus.CHARGING, + 194: ChargerStatus.CHARGING, + 195: ChargerStatus.CHARGING, + 196: ChargerStatus.DISCHARGING, + 209: ChargerStatus.LOCKED, + 210: ChargerStatus.LOCKED_CAR_CONNECTED, +} diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py new file mode 100644 index 00000000000..eaa425a53ef --- /dev/null +++ b/homeassistant/components/wallbox/coordinator.py @@ -0,0 +1,159 @@ +"""DataUpdateCoordinator for the wallbox integration.""" +from __future__ import annotations + +from datetime import timedelta +from http import HTTPStatus +import logging +from typing import Any + +import requests +from wallbox import Wallbox + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CHARGER_CURRENCY_KEY, + CHARGER_DATA_KEY, + CHARGER_ENERGY_PRICE_KEY, + CHARGER_LOCKED_UNLOCKED_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, + CHARGER_STATUS, + CHARGER_STATUS_DESCRIPTION_KEY, + CHARGER_STATUS_ID_KEY, + CODE_KEY, + DOMAIN, + UPDATE_INTERVAL, + ChargerStatus, +) + +_LOGGER = logging.getLogger(__name__) + + +class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Wallbox Coordinator class.""" + + def __init__(self, station: str, wallbox: Wallbox, hass: HomeAssistant) -> None: + """Initialize.""" + self._station = station + self._wallbox = wallbox + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + def _authenticate(self) -> None: + """Authenticate using Wallbox API.""" + try: + self._wallbox.authenticate() + + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == HTTPStatus.FORBIDDEN: + raise ConfigEntryAuthFailed from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + def _validate(self) -> None: + """Authenticate using Wallbox API.""" + try: + self._wallbox.authenticate() + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_validate_input(self) -> None: + """Get new sensor data for Wallbox component.""" + await self.hass.async_add_executor_job(self._validate) + + def _get_data(self) -> dict[str, Any]: + """Get new sensor data for Wallbox component.""" + try: + self._authenticate() + data: dict[str, Any] = self._wallbox.getChargerStatus(self._station) + data[CHARGER_MAX_CHARGING_CURRENT_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_MAX_CHARGING_CURRENT_KEY + ] + data[CHARGER_LOCKED_UNLOCKED_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_LOCKED_UNLOCKED_KEY + ] + data[CHARGER_ENERGY_PRICE_KEY] = data[CHARGER_DATA_KEY][ + CHARGER_ENERGY_PRICE_KEY + ] + data[ + CHARGER_CURRENCY_KEY + ] = f"{data[CHARGER_DATA_KEY][CHARGER_CURRENCY_KEY][CODE_KEY]}/kWh" + + data[CHARGER_STATUS_DESCRIPTION_KEY] = CHARGER_STATUS.get( + data[CHARGER_STATUS_ID_KEY], ChargerStatus.UNKNOWN + ) + return data + except ( + ConnectionError, + requests.exceptions.HTTPError, + ) as wallbox_connection_error: + raise UpdateFailed from wallbox_connection_error + + async def _async_update_data(self) -> dict[str, Any]: + """Get new sensor data for Wallbox component.""" + return await self.hass.async_add_executor_job(self._get_data) + + def _set_charging_current(self, charging_current: float) -> None: + """Set maximum charging current for Wallbox.""" + try: + self._authenticate() + self._wallbox.setMaxChargingCurrent(self._station, charging_current) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_charging_current(self, charging_current: float) -> None: + """Set maximum charging current for Wallbox.""" + await self.hass.async_add_executor_job( + self._set_charging_current, charging_current + ) + await self.async_request_refresh() + + def _set_lock_unlock(self, lock: bool) -> None: + """Set wallbox to locked or unlocked.""" + try: + self._authenticate() + if lock: + self._wallbox.lockCharger(self._station) + else: + self._wallbox.unlockCharger(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_lock_unlock(self, lock: bool) -> None: + """Set wallbox to locked or unlocked.""" + await self.hass.async_add_executor_job(self._set_lock_unlock, lock) + await self.async_request_refresh() + + def _pause_charger(self, pause: bool) -> None: + """Set wallbox to pause or resume.""" + try: + self._authenticate() + if pause: + self._wallbox.pauseChargingSession(self._station) + else: + self._wallbox.resumeChargingSession(self._station) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_pause_charger(self, pause: bool) -> None: + """Set wallbox to pause or resume.""" + await self.hass.async_add_executor_job(self._pause_charger, pause) + await self.async_request_refresh() + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/wallbox/entity.py b/homeassistant/components/wallbox/entity.py index c9d12643768..1152530dbd1 100644 --- a/homeassistant/components/wallbox/entity.py +++ b/homeassistant/components/wallbox/entity.py @@ -4,7 +4,6 @@ from __future__ import annotations from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import WallboxCoordinator from .const import ( CHARGER_CURRENT_VERSION_KEY, CHARGER_DATA_KEY, @@ -14,6 +13,7 @@ from .const import ( CHARGER_SOFTWARE_KEY, DOMAIN, ) +from .coordinator import WallboxCoordinator class WallboxEntity(CoordinatorEntity[WallboxCoordinator]): diff --git a/homeassistant/components/wallbox/lock.py b/homeassistant/components/wallbox/lock.py index 1a2364880f1..11a66a4814c 100644 --- a/homeassistant/components/wallbox/lock.py +++ b/homeassistant/components/wallbox/lock.py @@ -9,13 +9,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator from .const import ( CHARGER_DATA_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .coordinator import InvalidAuth, WallboxCoordinator from .entity import WallboxEntity LOCK_TYPES: dict[str, LockEntityDescription] = { diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index cea7bd5ce81..d53a842b916 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -13,7 +13,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import InvalidAuth, WallboxCoordinator from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, CHARGER_DATA_KEY, @@ -23,6 +22,7 @@ from .const import ( CHARGER_SERIAL_NUMBER_KEY, DOMAIN, ) +from .coordinator import InvalidAuth, WallboxCoordinator from .entity import WallboxEntity diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index 7a08bdd7c02..4a1cf365bb1 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -23,7 +23,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import WallboxCoordinator from .const import ( CHARGER_ADDED_DISCHARGED_ENERGY_KEY, CHARGER_ADDED_ENERGY_KEY, @@ -43,6 +42,7 @@ from .const import ( CHARGER_STATUS_DESCRIPTION_KEY, DOMAIN, ) +from .coordinator import WallboxCoordinator from .entity import WallboxEntity CHARGER_STATION = "station" diff --git a/homeassistant/components/wallbox/switch.py b/homeassistant/components/wallbox/switch.py index f59e64a516f..2de6379eb18 100644 --- a/homeassistant/components/wallbox/switch.py +++ b/homeassistant/components/wallbox/switch.py @@ -8,7 +8,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import WallboxCoordinator from .const import ( CHARGER_DATA_KEY, CHARGER_PAUSE_RESUME_KEY, @@ -17,6 +16,7 @@ from .const import ( DOMAIN, ChargerStatus, ) +from .coordinator import WallboxCoordinator from .entity import WallboxEntity SWITCH_TYPES: dict[str, SwitchEntityDescription] = { diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 2afe2d245a8..0091ce9ffdc 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -3,7 +3,10 @@ import json import requests_mock -from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY, DOMAIN +from homeassistant.components.wallbox.const import ( + CHARGER_MAX_CHARGING_CURRENT_KEY, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index f812d27d8c2..065a43b2789 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -5,7 +5,7 @@ import pytest import requests_mock from homeassistant.components.lock import SERVICE_LOCK, SERVICE_UNLOCK -from homeassistant.components.wallbox import CHARGER_LOCKED_UNLOCKED_KEY +from homeassistant.components.wallbox.const import CHARGER_LOCKED_UNLOCKED_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 8f3e6274220..9d1663bf002 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -5,7 +5,7 @@ import pytest import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY +from homeassistant.components.wallbox.const import CHARGER_MAX_CHARGING_CURRENT_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 2b4d49b5af9..9418b4d8765 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -5,8 +5,8 @@ import pytest import requests_mock from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON -from homeassistant.components.wallbox import InvalidAuth from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant From 3018d4edb90213d578f1a8951e7f2d5bfc35d07e Mon Sep 17 00:00:00 2001 From: SmashedFrenzy16 Date: Sat, 7 Oct 2023 12:37:19 +0100 Subject: [PATCH 266/968] Update config.py with f string (#101333) Co-authored-by: Franck Nijhof --- homeassistant/config.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/config.py b/homeassistant/config.py index 6a0425f971e..8d316eb773b 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -378,7 +378,9 @@ def _write_default_config(config_dir: str) -> bool: return True except OSError: - print("Unable to create default configuration file", config_path) # noqa: T201 + print( # noqa: T201 + f"Unable to create default configuration file {config_path}" + ) return False From 35be5957c39f6937b8e76bbf5805e50bf7b2e9f8 Mon Sep 17 00:00:00 2001 From: enzo2 <542271+enzo2@users.noreply.github.com> Date: Sat, 7 Oct 2023 07:51:27 -0400 Subject: [PATCH 267/968] Add circular mean to statistics integration (#98930) * Add circular mean Add support for circular mean for sensors in units of degrees, e.g. direction data. * Update test_sensor.py * Update sensor.py * Remove whitespace * Revert to degC * Fix: shift atan2 output to positive degrees * Add new dedicated test * Simplify test --- homeassistant/components/statistics/sensor.py | 11 +++++ tests/components/statistics/test_sensor.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index e86a4741080..90cb80a9642 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -6,6 +6,7 @@ from collections.abc import Callable import contextlib from datetime import datetime, timedelta import logging +import math import statistics from typing import Any, cast @@ -82,6 +83,7 @@ STAT_DISTANCE_95P = "distance_95_percent_of_values" STAT_DISTANCE_99P = "distance_99_percent_of_values" STAT_DISTANCE_ABSOLUTE = "distance_absolute" STAT_MEAN = "mean" +STAT_MEAN_CIRCULAR = "mean_circular" STAT_MEDIAN = "median" STAT_NOISINESS = "noisiness" STAT_PERCENTILE = "percentile" @@ -111,6 +113,7 @@ STATS_NUMERIC_SUPPORT = { STAT_DISTANCE_99P, STAT_DISTANCE_ABSOLUTE, STAT_MEAN, + STAT_MEAN_CIRCULAR, STAT_MEDIAN, STAT_NOISINESS, STAT_PERCENTILE, @@ -160,6 +163,7 @@ STATS_NUMERIC_RETAIN_UNIT = { STAT_DISTANCE_99P, STAT_DISTANCE_ABSOLUTE, STAT_MEAN, + STAT_MEAN_CIRCULAR, STAT_MEDIAN, STAT_NOISINESS, STAT_PERCENTILE, @@ -681,6 +685,13 @@ class StatisticsSensor(SensorEntity): return statistics.mean(self.states) return None + def _stat_mean_circular(self) -> StateType: + if len(self.states) > 0: + sin_sum = sum(math.sin(math.radians(x)) for x in self.states) + cos_sum = sum(math.cos(math.radians(x)) for x in self.states) + return (math.degrees(math.atan2(sin_sum, cos_sum)) + 360) % 360 + return None + def _stat_median(self) -> StateType: if len(self.states) > 0: return statistics.median(self.states) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 780e550f224..13330770978 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -22,6 +22,7 @@ from homeassistant.components.statistics.sensor import StatisticsSensor from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, + DEGREE, SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -920,6 +921,14 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: "value_9": float(round(sum(VALUES_NUMERIC) / len(VALUES_NUMERIC), 2)), "unit": "°C", }, + { + "source_sensor_domain": "sensor", + "name": "mean_circular", + "value_0": STATE_UNKNOWN, + "value_1": float(VALUES_NUMERIC[-1]), + "value_9": 10.76, + "unit": "°C", + }, { "source_sensor_domain": "sensor", "name": "median", @@ -1207,6 +1216,43 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: ) +async def test_state_characteristic_mean_circular(hass: HomeAssistant) -> None: + """Test the mean_circular state characteristic using angle data.""" + values_angular = [0, 10, 90.5, 180, 269.5, 350] + + assert await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "statistics", + "name": "test_sensor_mean_circular", + "entity_id": "sensor.test_monitored", + "state_characteristic": "mean_circular", + "sampling_size": 6, + }, + ] + }, + ) + await hass.async_block_till_done() + + for angle in values_angular: + hass.states.async_set( + "sensor.test_monitored", + str(angle), + {ATTR_UNIT_OF_MEASUREMENT: DEGREE}, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.test_sensor_mean_circular") + assert state is not None + assert state.state == "0.0", ( + "value mismatch for characteristic 'sensor/mean_circular' - " + f"assert {state.state} == 0.0" + ) + + async def test_invalid_state_characteristic(hass: HomeAssistant) -> None: """Test the detection of wrong state_characteristics selected.""" assert await async_setup_component( From f3864e6e2fa3b2ea28fd6334a4b72ea459477953 Mon Sep 17 00:00:00 2001 From: Anil Daoud Date: Sat, 7 Oct 2023 20:19:57 +0800 Subject: [PATCH 268/968] Handle ClientConnectorError in Netatmo data handler (#99116) --- homeassistant/components/netatmo/data_handler.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/netatmo/data_handler.py b/homeassistant/components/netatmo/data_handler.py index c80bd351ccf..8fa8ab2073d 100644 --- a/homeassistant/components/netatmo/data_handler.py +++ b/homeassistant/components/netatmo/data_handler.py @@ -10,6 +10,7 @@ import logging from time import time from typing import Any +import aiohttp import pyatmo from pyatmo.modules.device_types import DeviceCategory as NetatmoDeviceCategory @@ -211,6 +212,10 @@ class NetatmoDataHandler: _LOGGER.debug(err) return + except aiohttp.ClientConnectorError as err: + _LOGGER.debug(err) + return + for update_callback in self.publisher[signal_name].subscriptions: if update_callback: update_callback() From 1a5ad23a10bc955651ae418c6fb4ef19af6907fe Mon Sep 17 00:00:00 2001 From: Aaron Collins Date: Sun, 8 Oct 2023 01:32:27 +1300 Subject: [PATCH 269/968] Verify config entry id on Daikin device removal (#101507) --- homeassistant/components/daikin/__init__.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py index eda7976e572..cc79f2ae233 100644 --- a/homeassistant/components/daikin/__init__.py +++ b/homeassistant/components/daikin/__init__.py @@ -168,9 +168,12 @@ async def async_migrate_unique_id( ent_reg, duplicate.id, True ) for entity in duplicate_entities: - ent_reg.async_remove(entity.entity_id) + if entity.config_entry_id == config_entry.entry_id: + ent_reg.async_remove(entity.entity_id) - dev_reg.async_remove_device(duplicate.id) + dev_reg.async_update_device( + duplicate.id, remove_config_entry_id=config_entry.entry_id + ) # Migrate devices for device_entry in dr.async_entries_for_config_entry( From b60401b2b12e430a411d93f066f364666b8be68c Mon Sep 17 00:00:00 2001 From: Alistair Tudor <3691326+atudor2@users.noreply.github.com> Date: Sat, 7 Oct 2023 15:00:04 +0200 Subject: [PATCH 270/968] Bump python-vlc to 3.0.18122 (#94739) --- homeassistant/components/vlc/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json index 971367763fc..7e4fb7b2a4f 100644 --- a/homeassistant/components/vlc/manifest.json +++ b/homeassistant/components/vlc/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/vlc", "iot_class": "local_polling", - "requirements": ["python-vlc==1.1.2"] + "requirements": ["python-vlc==3.0.18122"] } diff --git a/requirements_all.txt b/requirements_all.txt index 68af87927d0..d6c316f051b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2189,7 +2189,7 @@ python-tado==0.15.0 python-telegram-bot==13.1 # homeassistant.components.vlc -python-vlc==1.1.2 +python-vlc==3.0.18122 # homeassistant.components.egardia pythonegardia==1.0.52 From 031a9224fb5efcdd1a9c5079f8e3cd29720f6a98 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 7 Oct 2023 09:04:23 -0400 Subject: [PATCH 271/968] Schlage cleanup: Stop passing logs to last_changed_by (#99738) --- homeassistant/components/schlage/lock.py | 6 +----- tests/components/schlage/test_lock.py | 22 +--------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/schlage/lock.py b/homeassistant/components/schlage/lock.py index ff9c60c0b55..0b5e35492de 100644 --- a/homeassistant/components/schlage/lock.py +++ b/homeassistant/components/schlage/lock.py @@ -48,11 +48,7 @@ class SchlageLockEntity(SchlageEntity, LockEntity): """Update our internal state attributes.""" self._attr_is_locked = self._lock.is_locked self._attr_is_jammed = self._lock.is_jammed - # Only update changed_by if we get a valid value. This way a previous - # value will stay intact if the latest log message isn't related to a - # lock state change. - if changed_by := self._lock.last_changed_by(self._lock_data.logs): - self._attr_changed_by = changed_by + self._attr_changed_by = self._lock.last_changed_by() async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" diff --git a/tests/components/schlage/test_lock.py b/tests/components/schlage/test_lock.py index bf32d76836c..0972aa97033 100644 --- a/tests/components/schlage/test_lock.py +++ b/tests/components/schlage/test_lock.py @@ -3,8 +3,6 @@ from datetime import timedelta from unittest.mock import Mock -from pyschlage.exceptions import UnknownError - from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK @@ -62,26 +60,8 @@ async def test_changed_by( # Make the coordinator refresh data. async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() - mock_lock.last_changed_by.assert_called_once_with([]) + mock_lock.last_changed_by.assert_called_once_with() lock_device = hass.states.get("lock.vault_door") assert lock_device is not None assert lock_device.attributes.get("changed_by") == "access code - foo" - - -async def test_changed_by_uses_previous_logs_on_failure( - hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry -) -> None: - """Test that a failure to load logs is not terminal.""" - mock_lock.last_changed_by.reset_mock() - mock_lock.last_changed_by.return_value = "thumbturn" - mock_lock.logs.side_effect = UnknownError("Cannot load logs") - - # Make the coordinator refresh data. - async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) - await hass.async_block_till_done() - mock_lock.last_changed_by.assert_called_once_with([]) - - lock_device = hass.states.get("lock.vault_door") - assert lock_device is not None - assert lock_device.attributes.get("changed_by") == "thumbturn" From bd93fbe91d69525a3546bf22fa5f698f0432cbaa Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 7 Oct 2023 18:14:08 +0200 Subject: [PATCH 272/968] Update aiohttp to 3.8.6 (#101590) --- 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 81b13e6ef9d..799de88f8e0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,5 @@ aiodiscover==1.5.1 -aiohttp==3.8.5 +aiohttp==3.8.6 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.1 diff --git a/pyproject.toml b/pyproject.toml index b0b82184785..7badc6be1e4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.8.5", + "aiohttp==3.8.6", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 60eb2359ba5..a7ede68c9ec 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.8.5 +aiohttp==3.8.6 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 From da3e36aa3bca392a0e5e9fdfbd20858bd1ecb62d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sat, 7 Oct 2023 17:52:31 +0100 Subject: [PATCH 273/968] Improve Ikea Idasen config flow error messages (#101567) --- .../components/idasen_desk/config_flow.py | 7 ++- .../components/idasen_desk/manifest.json | 2 +- .../components/idasen_desk/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../idasen_desk/test_config_flow.py | 55 ++++++++++++++++++- 6 files changed, 64 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index f56446396d2..92f5a836751 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -4,9 +4,9 @@ from __future__ import annotations import logging from typing import Any -from bleak import BleakError +from bleak.exc import BleakError from bluetooth_data_tools import human_readable_name -from idasen_ha import Desk +from idasen_ha import AuthFailedError, Desk import voluptuous as vol from homeassistant import config_entries @@ -64,6 +64,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): desk = Desk(None) try: await desk.connect(discovery_info.device, monitor_height=False) + except AuthFailedError as err: + _LOGGER.exception("AuthFailedError", exc_info=err) + errors["base"] = "auth_failed" except TimeoutError as err: _LOGGER.exception("TimeoutError", exc_info=err) errors["base"] = "cannot_connect" diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index f77e0c22373..cdb06cf907d 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,5 +11,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", - "requirements": ["idasen-ha==1.4"] + "requirements": ["idasen-ha==1.4.1"] } diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json index f7459906ac8..6b9bf80edfc 100644 --- a/homeassistant/components/idasen_desk/strings.json +++ b/homeassistant/components/idasen_desk/strings.json @@ -9,7 +9,8 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "auth_failed": "Unable to authenticate with the desk. This is usually solved by using an ESPHome Bluetooth Proxy. Please check the integration documentation for alternative workarounds.", + "cannot_connect": "Cannot connect. Make sure that the desk is in Bluetooth pairing mode. If not already, you can also use an ESPHome Bluetooth Proxy, as it provides a better connection.", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/requirements_all.txt b/requirements_all.txt index d6c316f051b..1e45cd32bc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,7 +1042,7 @@ ical==5.0.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4 +idasen-ha==1.4.1 # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d3e78b3b5f..b4864d6ef8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -822,7 +822,7 @@ ical==5.0.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4 +idasen-ha==1.4.1 # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index 8635e5bfddc..223ecc55e28 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import patch from bleak import BleakError +from idasen_ha import AuthFailedError import pytest from homeassistant import config_entries @@ -89,7 +90,7 @@ async def test_user_step_no_new_devices_found(hass: HomeAssistant) -> None: async def test_user_step_cannot_connect( hass: HomeAssistant, exception: Exception ) -> None: - """Test user step and we cannot connect.""" + """Test user step with a cannot connect error.""" with patch( "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", return_value=[IDASEN_DISCOVERY_INFO], @@ -140,6 +141,58 @@ async def test_user_step_cannot_connect( assert len(mock_setup_entry.mock_calls) == 1 +async def test_user_step_auth_failed(hass: HomeAssistant) -> None: + """Test user step with an auth failed error.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[IDASEN_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect", + side_effect=AuthFailedError, + ), patch("homeassistant.components.idasen_desk.config_flow.Desk.disconnect"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "auth_failed"} + + with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == IDASEN_DISCOVERY_INFO.name + assert result3["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result3["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + async def test_user_step_unknown_exception(hass: HomeAssistant) -> None: """Test user step with an unknown exception.""" with patch( From 8c2a2e5c37822e779fb40df9ca40a73143a2cd2e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 7 Oct 2023 10:17:08 -0700 Subject: [PATCH 274/968] Additional fix for rainbird unique id (#101599) Additiona fix for rainbird unique id --- .../components/rainbird/binary_sensor.py | 2 +- .../components/rainbird/coordinator.py | 2 +- .../components/rainbird/test_binary_sensor.py | 31 +++++++++++++++++-- tests/components/rainbird/test_number.py | 30 ++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainbird/binary_sensor.py b/homeassistant/components/rainbird/binary_sensor.py index 3333d8bc4cb..142e8ecc4b8 100644 --- a/homeassistant/components/rainbird/binary_sensor.py +++ b/homeassistant/components/rainbird/binary_sensor.py @@ -48,7 +48,7 @@ class RainBirdSensor(CoordinatorEntity[RainbirdUpdateCoordinator], BinarySensorE """Initialize the Rain Bird sensor.""" super().__init__(coordinator) self.entity_description = description - if coordinator.unique_id: + if coordinator.unique_id is not None: self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" self._attr_device_info = coordinator.device_info else: diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index 763e50fe5d9..9f1ea95b333 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -84,7 +84,7 @@ class RainbirdUpdateCoordinator(DataUpdateCoordinator[RainbirdDeviceState]): @property def device_info(self) -> DeviceInfo | None: """Return information about the device.""" - if not self._unique_id: + if self._unique_id is None: return None return DeviceInfo( name=self.device_name, diff --git a/tests/components/rainbird/test_binary_sensor.py b/tests/components/rainbird/test_binary_sensor.py index e372a10ae23..24cd1750ed4 100644 --- a/tests/components/rainbird/test_binary_sensor.py +++ b/tests/components/rainbird/test_binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, ComponentSetup +from .conftest import RAIN_SENSOR_OFF, RAIN_SENSOR_ON, SERIAL_NUMBER, ComponentSetup from tests.test_util.aiohttp import AiohttpClientMockResponse @@ -41,11 +41,38 @@ async def test_rainsensor( "icon": "mdi:water", } + +@pytest.mark.parametrize( + ("config_entry_unique_id", "entity_unique_id"), + [ + (SERIAL_NUMBER, "1263613994342-rainsensor"), + # Some existing config entries may have a "0" serial number but preserve + # their unique id + (0, "0-rainsensor"), + ], +) +async def test_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + entity_unique_id: str, +) -> None: + """Test rainsensor binary sensor.""" + + assert await setup_integration() + + rainsensor = hass.states.get("binary_sensor.rain_bird_controller_rainsensor") + assert rainsensor is not None + assert rainsensor.attributes == { + "friendly_name": "Rain Bird Controller Rainsensor", + "icon": "mdi:water", + } + entity_entry = entity_registry.async_get( "binary_sensor.rain_bird_controller_rainsensor" ) assert entity_entry - assert entity_entry.unique_id == "1263613994342-rainsensor" + assert entity_entry.unique_id == entity_unique_id @pytest.mark.parametrize( diff --git a/tests/components/rainbird/test_number.py b/tests/components/rainbird/test_number.py index 5d208f08a25..b3cfd56832d 100644 --- a/tests/components/rainbird/test_number.py +++ b/tests/components/rainbird/test_number.py @@ -63,6 +63,36 @@ async def test_number_values( assert entity_entry.unique_id == "1263613994342-rain-delay" +@pytest.mark.parametrize( + ("config_entry_unique_id", "entity_unique_id"), + [ + (SERIAL_NUMBER, "1263613994342-rain-delay"), + # Some existing config entries may have a "0" serial number but preserve + # their unique id + (0, "0-rain-delay"), + ], +) +async def test_unique_id( + hass: HomeAssistant, + setup_integration: ComponentSetup, + entity_registry: er.EntityRegistry, + entity_unique_id: str, +) -> None: + """Test number platform.""" + + assert await setup_integration() + + raindelay = hass.states.get("number.rain_bird_controller_rain_delay") + assert raindelay is not None + assert ( + raindelay.attributes.get("friendly_name") == "Rain Bird Controller Rain delay" + ) + + entity_entry = entity_registry.async_get("number.rain_bird_controller_rain_delay") + assert entity_entry + assert entity_entry.unique_id == entity_unique_id + + async def test_set_value( hass: HomeAssistant, setup_integration: ComponentSetup, From 4709e60ff64c7e7ba3930d3ec312980bc339bf72 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 7 Oct 2023 19:39:04 +0200 Subject: [PATCH 275/968] Rework on Google Assistant doorbell support (#100930) * Rework on Google Assistant doorbell event * Additional comment on syncing notificatiions * Update homeassistant/components/google_assistant/trait.py * Only sync event if state attr changed * Update comment --- homeassistant/components/google_assistant/helpers.py | 2 +- .../components/google_assistant/report_state.py | 10 +++++++++- homeassistant/components/google_assistant/trait.py | 6 +++++- tests/components/google_assistant/test_trait.py | 2 +- 4 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index ee8e5872348..b2cda5522ee 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -240,7 +240,7 @@ class AbstractConfig(ABC): async def async_sync_notification( self, agent_user_id: str, event_id: str, payload: dict[str, Any] ) -> HTTPStatus: - """Sync notification to Google.""" + """Sync notifications to Google.""" # Remove any pending sync self._google_sync_unsub.pop(agent_user_id, lambda: None)() status = await self.async_report_state(payload, agent_user_id, event_id) diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 87af12ad0fc..aec50011200 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -80,7 +80,15 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig ): return - if (notifications := entity.notifications_serialize()) is not None: + # We only trigger notifications on changes in the state value, not attributes. + # This is mainly designed for our event entity types + # We need to synchronize notifications using a `SYNC` response, + # together with other state changes. + if ( + old_state + and old_state.state != new_state.state + and (notifications := entity.notifications_serialize()) is not None + ): event_id = uuid4().hex payload = { "devices": {"notifications": {entity.state.entity_id: notifications}} diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a39dfd3f3dc..a3b6638de11 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -382,10 +382,14 @@ class ObjectDetection(_Trait): return None # Only notify if last event was less then 30 seconds ago - time_stamp = datetime.fromisoformat(self.state.state) + time_stamp: datetime = datetime.fromisoformat(self.state.state) if (utcnow() - time_stamp) > timedelta(seconds=30): return None + # A doorbell event is treated as an object detection of 1 unclassified object. + # The implementation follows the pattern from the Smart Home Doorbell Guide: + # https://developers.home.google.com/cloud-to-cloud/guides/doorbell + # The detectionTimestamp is the time in ms from January 1, 1970, 00:00:00 (UTC) return { "ObjectDetection": { "objects": { diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index db4257bb621..903ba5ca036 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -223,7 +223,7 @@ async def test_onoff_input_boolean(hass: HomeAssistant) -> None: @pytest.mark.freeze_time("2023-08-01T00:02:57+00:00") async def test_doorbell_event(hass: HomeAssistant) -> None: - """Test doorbell event trait support for input_boolean domain.""" + """Test doorbell event trait support for event domain.""" assert trait.ObjectDetection.supported(event.DOMAIN, 0, "doorbell", None) state = State( From f4ac2b7eeb130c7b0739f181166eb6073451b7f3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 7 Oct 2023 20:48:35 +0200 Subject: [PATCH 276/968] Remove platform key and rename schema for mqtt tag (#101580) --- homeassistant/components/mqtt/tag.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 848950169d8..e87a9b0da6e 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -8,7 +8,7 @@ import voluptuous as vol from homeassistant.components import tag from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEVICE, CONF_PLATFORM, CONF_VALUE_TEMPLATE +from homeassistant.const import CONF_DEVICE, CONF_VALUE_TEMPLATE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -33,10 +33,9 @@ LOG_NAME = "Tag" TAG = "tag" -PLATFORM_SCHEMA = MQTT_BASE_SCHEMA.extend( +DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, - vol.Optional(CONF_PLATFORM): "mqtt", vol.Required(CONF_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }, @@ -48,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT tag scanner dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, TAG, setup, PLATFORM_SCHEMA) + await async_setup_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) async def _async_setup_tag( @@ -121,7 +120,7 @@ class MQTTTagScanner(MqttDiscoveryDeviceUpdate): """Handle MQTT tag discovery updates.""" # Update tag scanner try: - config: DiscoveryInfoType = PLATFORM_SCHEMA(discovery_data) + config: DiscoveryInfoType = DISCOVERY_SCHEMA(discovery_data) except vol.Invalid as err: async_handle_schema_error(discovery_data, err) return From 9407c4981952cc961f3fddd13a6090529d60aa8a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 7 Oct 2023 20:49:05 +0200 Subject: [PATCH 277/968] Remove platform key and rename schema for mqtt device_automation (#101582) Refactor mqtt device_automation --- homeassistant/components/mqtt/device_automation.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index a1bc2cdeb3e..13d0b9ea530 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -7,7 +7,6 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import device_trigger @@ -19,17 +18,17 @@ AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) CONF_AUTOMATION_TYPE = "automation_type" -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( +DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, extra=vol.ALLOW_EXTRA, -).extend(MQTT_BASE_SCHEMA.schema) +) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Set up MQTT device automation dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, "device_automation", setup, PLATFORM_SCHEMA) + await async_setup_entry_helper(hass, "device_automation", setup, DISCOVERY_SCHEMA) async def _async_setup_automation( From 55bf309d2f9b5bde2025fbae9f0d101b8755893b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 7 Oct 2023 21:00:33 +0200 Subject: [PATCH 278/968] Add mqtt discovery schema error tests for all platforms (#101583) Add mqtt discovery schema error tests --- tests/components/mqtt/test_discovery.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 4d0b8457049..d2f6350a801 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -122,23 +122,24 @@ async def test_invalid_json( assert not mock_dispatcher_send.called +@pytest.mark.parametrize("domain", [*list(mqtt.PLATFORMS), "device_automation", "tag"]) @pytest.mark.no_fail_on_log_exception -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_discovery_schema_error( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + domain: Platform | str, ) -> None: """Test unexpected error JSON config.""" with patch( - "homeassistant.components.mqtt.binary_sensor.DISCOVERY_SCHEMA", + f"homeassistant.components.mqtt.{domain}.DISCOVERY_SCHEMA", side_effect=AttributeError("Attribute abc not found"), ): await mqtt_mock_entry() async_fire_mqtt_message( hass, - "homeassistant/binary_sensor/bla/config", - '{"name": "Beer", "state_topic": "ok"}', + f"homeassistant/{domain}/bla/config", + '{"name": "Beer", "some_topic": "bla"}', ) await hass.async_block_till_done() assert "AttributeError: Attribute abc not found" in caplog.text From 3bbef476eeacbd483ae821698eb69f33b10fba69 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 7 Oct 2023 22:10:07 +0200 Subject: [PATCH 279/968] Update tank-utility to 1.5.0 (#101323) --- homeassistant/components/tank_utility/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json index 3f4d7bbaa15..d73c62fa5ec 100644 --- a/homeassistant/components/tank_utility/manifest.json +++ b/homeassistant/components/tank_utility/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/tank_utility", "iot_class": "cloud_polling", "loggers": ["tank_utility"], - "requirements": ["tank-utility==1.4.1"] + "requirements": ["tank-utility==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e45cd32bc1..053b895d655 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2529,7 +2529,7 @@ systembridgeconnector==3.8.2 tailscale==0.2.0 # homeassistant.components.tank_utility -tank-utility==1.4.1 +tank-utility==1.5.0 # homeassistant.components.tapsaff tapsaff==0.2.1 From 74464fd94e72a221399e7dd0c6be3f3e01ac74a0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 7 Oct 2023 23:08:34 +0200 Subject: [PATCH 280/968] Ensure coverage mqtt entry disabled test (#101617) --- tests/components/mqtt/test_init.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index ccd175fe296..3cb1188dccf 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3545,15 +3545,16 @@ async def test_publish_or_subscribe_without_valid_config_entry( await mqtt.async_subscribe(hass, "some-topic", record_calls, qos=0) -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@patch( + "homeassistant.components.mqtt.PLATFORMS", + ["tag", Platform.LIGHT], +) @pytest.mark.parametrize( "hass_config", [ { "mqtt": { - "light": [ - {"name": "test_new_modern", "command_topic": "test-topic_new"} - ] + "light": [{"name": "test", "command_topic": "test-topic"}], } } ], @@ -3567,9 +3568,11 @@ async def test_disabling_and_enabling_entry( await mqtt_mock_entry() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert entry.state is ConfigEntryState.LOADED - # Late discovery of a light - config = '{"name": "abc", "command_topic": "test-topic"}' - async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config) + # Late discovery of a mqtt entity/tag + config_tag = '{"topic": "0AFFD2/tag_scanned", "value_template": "{{ value_json.PN532.UID }}"}' + config_light = '{"name": "test2", "command_topic": "test-topic_new"}' + async_fire_mqtt_message(hass, "homeassistant/tag/abc/config", config_tag) + async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config_light) # Disable MQTT config entry await hass.config_entries.async_set_disabled_by( @@ -3578,6 +3581,10 @@ async def test_disabling_and_enabling_entry( await hass.async_block_till_done() await hass.async_block_till_done() + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT tag" + in caplog.text + ) assert ( "MQTT integration is disabled, skipping setup of discovered item MQTT light" in caplog.text @@ -3593,7 +3600,7 @@ async def test_disabling_and_enabling_entry( new_mqtt_config_entry = entry assert new_mqtt_config_entry.state is ConfigEntryState.LOADED - assert hass.states.get("light.test_new_modern") is not None + assert hass.states.get("light.test") is not None @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) From d3a67cd9849684f12a8a1bba13776d50eed4e19c Mon Sep 17 00:00:00 2001 From: Matthew Donoughe Date: Sun, 8 Oct 2023 02:24:32 -0400 Subject: [PATCH 281/968] Update pylutron-caseta to 0.18.3 (#101630) --- homeassistant/components/lutron_caseta/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json index bf6ed32c668..ff2831950c6 100644 --- a/homeassistant/components/lutron_caseta/manifest.json +++ b/homeassistant/components/lutron_caseta/manifest.json @@ -9,7 +9,7 @@ }, "iot_class": "local_push", "loggers": ["pylutron_caseta"], - "requirements": ["pylutron-caseta==0.18.2"], + "requirements": ["pylutron-caseta==0.18.3"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 053b895d655..fa5b2b8d87d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1833,7 +1833,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.2 +pylutron-caseta==0.18.3 # homeassistant.components.lutron pylutron==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4864d6ef8d..ef2be36c232 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1379,7 +1379,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.9 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.2 +pylutron-caseta==0.18.3 # homeassistant.components.mailgun pymailgunner==1.4 From 7d202f78f561a9276b22a0e931836ed510429a7c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Oct 2023 01:09:26 -0700 Subject: [PATCH 282/968] Add fitbit nutrition sensors (#101626) * Add fitbit nutrition sensors * Add test coverage for unit systems --- homeassistant/components/fitbit/sensor.py | 26 ++++++++++ .../fitbit/snapshots/test_sensor.ambr | 48 +++++++++++++++++++ tests/components/fitbit/test_sensor.py | 38 +++++++++++++++ 3 files changed, 112 insertions(+) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index c7c5e3258ed..51b1b64a391 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( UnitOfLength, UnitOfMass, UnitOfTime, + UnitOfVolume, ) from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -122,6 +123,13 @@ def _elevation_unit(unit_system: FitbitUnitSystem) -> UnitOfLength: return UnitOfLength.METERS +def _water_unit(unit_system: FitbitUnitSystem) -> UnitOfVolume: + """Determine the water unit.""" + if unit_system == FitbitUnitSystem.EN_US: + return UnitOfVolume.FLUID_OUNCES + return UnitOfVolume.MILLILITERS + + @dataclass class FitbitSensorEntityDescription(SensorEntityDescription): """Describes Fitbit sensor entity.""" @@ -453,6 +461,24 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), + FitbitSensorEntityDescription( + key="foods/log/caloriesIn", + name="Calories In", + native_unit_of_measurement="cal", + icon="mdi:food-apple", + state_class=SensorStateClass.TOTAL_INCREASING, + scope="nutrition", + entity_category=EntityCategory.DIAGNOSTIC, + ), + FitbitSensorEntityDescription( + key="foods/log/water", + name="Water", + icon="mdi:cup-water", + unit_fn=_water_unit, + state_class=SensorStateClass.TOTAL_INCREASING, + scope="nutrition", + entity_category=EntityCategory.DIAGNOSTIC, + ), ) # Different description depending on clock format diff --git a/tests/components/fitbit/snapshots/test_sensor.ambr b/tests/components/fitbit/snapshots/test_sensor.ambr index 4af82c6815a..55b2639a56d 100644 --- a/tests/components/fitbit/snapshots/test_sensor.ambr +++ b/tests/components/fitbit/snapshots/test_sensor.ambr @@ -1,4 +1,52 @@ # serializer version: 1 +# name: test_nutrition_scope_config_entry[scopes0-unit_system0] + tuple( + '99', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Water', + 'icon': 'mdi:cup-water', + 'state_class': , + 'unit_of_measurement': , + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes0-unit_system0].1 + tuple( + '1600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories In', + 'icon': 'mdi:food-apple', + 'state_class': , + 'unit_of_measurement': 'cal', + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes1-unit_system1] + tuple( + '99', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Water', + 'icon': 'mdi:cup-water', + 'state_class': , + 'unit_of_measurement': , + }), + ) +# --- +# name: test_nutrition_scope_config_entry[scopes1-unit_system1].1 + tuple( + '1600', + ReadOnlyDict({ + 'attribution': 'Data provided by Fitbit.com', + 'friendly_name': 'Calories In', + 'icon': 'mdi:food-apple', + 'state_class': , + 'unit_of_measurement': 'cal', + }), + ) +# --- # name: test_sensors[monitored_resources0-sensor.activity_calories-activities/activityCalories-135] tuple( '135', diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 2eb29db43de..7c980ac84a7 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -14,6 +14,11 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity +from homeassistant.util.unit_system import ( + METRIC_SYSTEM, + US_CUSTOMARY_SYSTEM, + UnitSystem, +) from .conftest import ( DEVICES_API_URL, @@ -426,6 +431,39 @@ async def test_heartrate_scope_config_entry( } +@pytest.mark.parametrize( + ("scopes", "unit_system"), + [(["nutrition"], METRIC_SYSTEM), (["nutrition"], US_CUSTOMARY_SYSTEM)], +) +async def test_nutrition_scope_config_entry( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + register_timeseries: Callable[[str, dict[str, Any]], None], + unit_system: UnitSystem, + snapshot: SnapshotAssertion, +) -> None: + """Test nutrition sensors are enabled.""" + hass.config.units = unit_system + register_timeseries( + "foods/log/water", + timeseries_response("foods-log-water", "99"), + ) + register_timeseries( + "foods/log/caloriesIn", + timeseries_response("foods-log-caloriesIn", "1600"), + ) + assert await integration_setup() + + state = hass.states.get("sensor.water") + assert state + assert (state.state, state.attributes) == snapshot + + state = hass.states.get("sensor.calories_in") + assert state + assert (state.state, state.attributes) == snapshot + + @pytest.mark.parametrize( ("scopes"), [(["sleep"])], From d05ba6cd922ae5a66b480a566d00469fb02ac013 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 8 Oct 2023 09:10:20 +0100 Subject: [PATCH 283/968] Bump systembridgeconnector to 3.8.4 (#101621) Update systembridgeconnector to 3.8.4 --- homeassistant/components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index bcc6189c8ef..64590ecb96f 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -10,6 +10,6 @@ "iot_class": "local_push", "loggers": ["systembridgeconnector"], "quality_scale": "silver", - "requirements": ["systembridgeconnector==3.8.2"], + "requirements": ["systembridgeconnector==3.8.4"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index fa5b2b8d87d..75bc8bbb00d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2523,7 +2523,7 @@ switchbot-api==1.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.8.2 +systembridgeconnector==3.8.4 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef2be36c232..fa7a20117f4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1877,7 +1877,7 @@ surepy==0.8.0 switchbot-api==1.1.0 # homeassistant.components.system_bridge -systembridgeconnector==3.8.2 +systembridgeconnector==3.8.4 # homeassistant.components.tailscale tailscale==0.2.0 From b8b28e3ba0356c8d16e78cee9159ec4b92a6422e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 8 Oct 2023 10:17:13 +0200 Subject: [PATCH 284/968] Bump pydiscovergy to 2.0.4 (#101637) --- homeassistant/components/discovergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 4223318ed93..7cfa8c4d1ee 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==2.0.3"] + "requirements": ["pydiscovergy==2.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 75bc8bbb00d..1e679f4b6f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1656,7 +1656,7 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.3 +pydiscovergy==2.0.4 # homeassistant.components.doods pydoods==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa7a20117f4..51a9025cbdc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1247,7 +1247,7 @@ pydeconz==113 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.3 +pydiscovergy==2.0.4 # homeassistant.components.hydrawise pydrawise==2023.10.0 From e406b8d1e309ab21754cbc016c09170cbd877fbc Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 8 Oct 2023 09:42:08 +0100 Subject: [PATCH 285/968] Address System Bridge post merge review (#101614) Address Post Merged PR Review for #97532 --- .../components/system_bridge/media_player.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/system_bridge/media_player.py b/homeassistant/components/system_bridge/media_player.py index c0d58c74c61..088c57573f1 100644 --- a/homeassistant/components/system_bridge/media_player.py +++ b/homeassistant/components/system_bridge/media_player.py @@ -54,6 +54,15 @@ MEDIA_SET_REPEAT_MAP: Final[dict[RepeatMode, int]] = { RepeatMode.ALL: 2, } +MEDIA_PLAYER_DESCRIPTION: Final[ + MediaPlayerEntityDescription +] = MediaPlayerEntityDescription( + key="media", + translation_key="media", + icon="mdi:volume-high", + device_class=MediaPlayerDeviceClass.RECEIVER, +) + async def async_setup_entry( hass: HomeAssistant, @@ -62,19 +71,14 @@ async def async_setup_entry( ) -> None: """Set up System Bridge media players based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - data: SystemBridgeCoordinatorData = coordinator.data + data = coordinator.data if data.media is not None: async_add_entities( [ SystemBridgeMediaPlayer( coordinator, - MediaPlayerEntityDescription( - key="media", - translation_key="media", - icon="mdi:volume-high", - device_class=MediaPlayerDeviceClass.RECEIVER, - ), + MEDIA_PLAYER_DESCRIPTION, entry.data[CONF_PORT], ) ] @@ -103,7 +107,7 @@ class SystemBridgeMediaPlayer(SystemBridgeEntity, MediaPlayerEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return self.coordinator.data.media is not None + return super().available and self.coordinator.data.media is not None @property def supported_features(self) -> MediaPlayerEntityFeature: From b75a59aad05f7640f98950e3b11049e2bfbbfc9c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 8 Oct 2023 12:44:27 +0200 Subject: [PATCH 286/968] Unregister callback on Netatmo config entry unload (#101647) --- homeassistant/components/netatmo/__init__.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 11cf167b85c..ddd2fc61ed7 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -242,19 +242,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if state is cloud.CloudConnectionState.CLOUD_DISCONNECTED: await unregister_webhook(None) - async_call_later(hass, 30, register_webhook) + entry.async_on_unload(async_call_later(hass, 30, register_webhook)) if cloud.async_active_subscription(hass): if cloud.async_is_connected(hass): await register_webhook(None) - cloud.async_listen_connection_change(hass, manage_cloudhook) + entry.async_on_unload( + cloud.async_listen_connection_change(hass, manage_cloudhook) + ) else: - async_at_started(hass, register_webhook) + entry.async_on_unload(async_at_started(hass, register_webhook)) hass.services.async_register(DOMAIN, "register_webhook", register_webhook) hass.services.async_register(DOMAIN, "unregister_webhook", unregister_webhook) - entry.add_update_listener(async_config_entry_updated) + entry.async_on_unload(entry.add_update_listener(async_config_entry_updated)) return True From 8c26f66a57ae64d1fb0937bfa048f9ebc50d5e50 Mon Sep 17 00:00:00 2001 From: Cyrille <2franix@users.noreply.github.com> Date: Sun, 8 Oct 2023 13:14:27 +0200 Subject: [PATCH 287/968] Suggest an ISO 8601 sample in datetime.set_value (#101609) --- homeassistant/components/datetime/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml index fb6f798e9bd..ddd733837a8 100644 --- a/homeassistant/components/datetime/services.yaml +++ b/homeassistant/components/datetime/services.yaml @@ -5,6 +5,6 @@ set_value: fields: datetime: required: true - example: "2022/11/01 22:15" + example: "2023-10-07T21:35:22" selector: datetime: From 3155e6251057cd72f21a8a97657ff9fd638075dc Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 8 Oct 2023 13:21:46 +0200 Subject: [PATCH 288/968] Update aiohttp to 3.9.0b0 (#101627) --- homeassistant/components/hassio/http.py | 5 ++--- homeassistant/helpers/aiohttp_client.py | 14 ++++++++++++-- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- tests/components/generic/test_camera.py | 2 +- tests/util/test_aiohttp.py | 2 +- 7 files changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 5bcdb6896cd..84b49af11c2 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -156,9 +156,8 @@ class HassIOView(HomeAssistantView): # _stored_content_type is only computed once `content_type` is accessed if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary - headers[ - CONTENT_TYPE - ] = request._stored_content_type # pylint: disable=protected-access + # pylint: disable-next=protected-access + headers[CONTENT_TYPE] = request._stored_content_type # type: ignore[assignment] try: client = await self._websession.request( diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index ac253d49254..1948d3bca95 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from contextlib import suppress -from ssl import SSLContext import sys from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -59,6 +58,17 @@ MAXIMUM_CONNECTIONS = 4096 MAXIMUM_CONNECTIONS_PER_HOST = 100 +# Overwrite base aiohttp _wait implementation +# Homeassistant has a custom shutdown wait logic. +async def _noop_wait(*args: Any, **kwargs: Any) -> None: + """Do nothing.""" + return + + +# pylint: disable-next=protected-access +web.BaseSite._wait = _noop_wait # type: ignore[method-assign] + + class HassClientResponse(aiohttp.ClientResponse): """aiohttp.ClientResponse with a json method that uses json_loads by default.""" @@ -276,7 +286,7 @@ def _async_get_connector( return cast(aiohttp.BaseConnector, hass.data[key]) if verify_ssl: - ssl_context: bool | SSLContext = ssl_util.get_default_context() + ssl_context = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 799de88f8e0..0a299d50239 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,5 @@ aiodiscover==1.5.1 -aiohttp==3.8.6 +aiohttp==3.9.0b0 aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.1 diff --git a/pyproject.toml b/pyproject.toml index 7badc6be1e4..0bae2c942d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.8.6", + "aiohttp==3.9.0b0", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index a7ede68c9ec..62c5ab5aede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.8.6 +aiohttp==3.9.0b0 astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index f7f7c390e0d..8bfd0a66dd5 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -164,7 +164,7 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") with pytest.raises(aiohttp.ServerTimeoutError), patch( - "async_timeout.timeout", side_effect=asyncio.TimeoutError() + "asyncio.timeout", side_effect=asyncio.TimeoutError() ): resp = await client.get("/api/camera_proxy/camera.config_test") diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index 76394b42491..496e6373ba5 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -51,7 +51,7 @@ def test_serialize_body_str() -> None: assert aiohttp.serialize_response(response) == { "status": 201, "body": "Hello", - "headers": {"Content-Length": "5", "Content-Type": "text/plain; charset=utf-8"}, + "headers": {"Content-Type": "text/plain; charset=utf-8"}, } From faea3b1634a5575dd509f991411f2041d96d3782 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 8 Oct 2023 13:32:35 +0200 Subject: [PATCH 289/968] Abort config flow when invalid token is received (#101642) --- .../components/withings/strings.json | 3 +- .../helpers/config_entry_oauth2_flow.py | 4 ++ tests/components/withings/test_config_flow.py | 51 +++++++++++++++++++ .../helpers/test_config_entry_oauth2_flow.py | 6 ++- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index df948a2b593..a9ba69ad045 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -16,7 +16,8 @@ "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "already_configured": "Configuration updated for profile.", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]" }, "create_entry": { "default": "Successfully authenticated with Withings." diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 6538c7fe891..1a106364566 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -319,6 +319,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): _LOGGER.error("Timeout resolving OAuth token: %s", err) return self.async_abort(reason="oauth2_timeout") + if "expires_in" not in token: + _LOGGER.warning("Invalid token: %s", token) + return self.async_abort(reason="oauth_error") + # Force int for non-compliant oauth2 providers try: token["expires_in"] = int(token["expires_in"]) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 36edffcc346..f8f8f62becf 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -253,3 +253,54 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +async def test_config_flow_with_invalid_credentials( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + polling_config_entry: MockConfigEntry, + withings: AsyncMock, + current_request_with_host, +) -> None: + """Test flow with invalid credentials.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + "https://account.withings.com/oauth2_user/authorize2?" + f"response_type=code&client_id={CLIENT_ID}&" + "redirect_uri=https://example.com/auth/external/callback&" + f"state={state}" + "&scope=user.info,user.metrics,user.activity,user.sleepevents" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "status": 503, + "error": "Invalid Params: invalid client id/secret", + }, + }, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "oauth_error" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 94cdf34cba3..c36b62f66c0 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -167,6 +167,7 @@ async def test_abort_if_no_url_available( assert result["reason"] == "no_url_available" +@pytest.mark.parametrize("expires_in_dict", [{}, {"expires_in": "badnumber"}]) async def test_abort_if_oauth_error( hass: HomeAssistant, flow_handler, @@ -174,6 +175,7 @@ async def test_abort_if_oauth_error( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, current_request_with_host: None, + expires_in_dict: dict[str, str], ) -> None: """Check bad oauth token.""" flow_handler.async_register_implementation(hass, local_impl) @@ -219,8 +221,8 @@ async def test_abort_if_oauth_error( "refresh_token": REFRESH_TOKEN, "access_token": ACCESS_TOKEN_1, "type": "bearer", - "expires_in": "badnumber", - }, + } + | expires_in_dict, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) From 0f4aae41282253d68640ec3320137b9b4afa050f Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Oct 2023 05:20:35 -0700 Subject: [PATCH 290/968] Add additional calendar state alarm debugging (#101631) --- homeassistant/components/calendar/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 5f6b54824fd..f868f951646 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -528,7 +528,9 @@ class CalendarEntity(Entity): the current or upcoming event. """ super().async_write_ha_state() - + _LOGGER.debug( + "Clearing %s alarms (%s)", self.entity_id, len(self._alarm_unsubs) + ) for unsub in self._alarm_unsubs: unsub() self._alarm_unsubs.clear() @@ -536,6 +538,7 @@ class CalendarEntity(Entity): now = dt_util.now() event = self.event if event is None or now >= event.end_datetime_local: + _LOGGER.debug("No alarms needed for %s (event=%s)", self.entity_id, event) return @callback From 7c85d841338e328b73cd2d8d3e3a701bbabf4c31 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 8 Oct 2023 15:15:19 +0200 Subject: [PATCH 291/968] Add entity translations to Huawei LTE (#98631) --- .../components/huawei_lte/binary_sensor.py | 10 +- homeassistant/components/huawei_lte/sensor.py | 139 +++++------ .../components/huawei_lte/strings.json | 230 ++++++++++++++++++ homeassistant/components/huawei_lte/switch.py | 4 +- tests/components/huawei_lte/test_switches.py | 2 +- 5 files changed, 308 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 2d96a4e0426..bf63422ae3a 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -104,7 +104,7 @@ CONNECTION_STATE_ATTRIBUTES = { class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" - _attr_name: str = field(default="Mobile connection", init=False) + _attr_translation_key: str = field(default="mobile_connection", init=False) _attr_entity_registry_enabled_default = True def __post_init__(self) -> None: @@ -169,7 +169,7 @@ class HuaweiLteBaseWifiStatusBinarySensor(HuaweiLteBaseBinarySensor): class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE WiFi status binary sensor.""" - _attr_name: str = field(default="WiFi status", init=False) + _attr_translation_key: str = field(default="wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -181,7 +181,7 @@ class HuaweiLteWifiStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 2.4GHz WiFi status binary sensor.""" - _attr_name: str = field(default="2.4GHz WiFi status", init=False) + _attr_translation_key: str = field(default="24ghz_wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -193,7 +193,7 @@ class HuaweiLteWifi24ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): """Huawei LTE 5GHz WiFi status binary sensor.""" - _attr_name: str = field(default="5GHz WiFi status", init=False) + _attr_translation_key: str = field(default="5ghz_wifi_status", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -205,7 +205,7 @@ class HuaweiLteWifi5ghzStatusBinarySensor(HuaweiLteBaseWifiStatusBinarySensor): class HuaweiLteSmsStorageFullBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE SMS storage full binary sensor.""" - _attr_name: str = field(default="SMS storage full", init=False) + _attr_translation_key: str = field(default="sms_storage_full", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index a4321bfd93f..07486297b32 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -137,7 +137,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "uptime": HuaweiSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, @@ -145,14 +145,14 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "WanIPAddress": HuaweiSensorEntityDescription( key="WanIPAddress", - name="WAN IP address", + translation_key="wan_ip_address", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=True, ), "WanIPv6Address": HuaweiSensorEntityDescription( key="WanIPv6Address", - name="WAN IPv6 address", + translation_key="wan_ipv6_address", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), @@ -165,61 +165,61 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "arfcn": HuaweiSensorEntityDescription( key="arfcn", - name="ARFCN", + translation_key="arfcn", entity_category=EntityCategory.DIAGNOSTIC, ), "band": HuaweiSensorEntityDescription( key="band", - name="Band", + translation_key="band", entity_category=EntityCategory.DIAGNOSTIC, ), "bsic": HuaweiSensorEntityDescription( key="bsic", - name="Base station identity code", + translation_key="base_station_identity_code", entity_category=EntityCategory.DIAGNOSTIC, ), "cell_id": HuaweiSensorEntityDescription( key="cell_id", - name="Cell ID", + translation_key="cell_id", icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi0": HuaweiSensorEntityDescription( key="cqi0", - name="CQI 0", + translation_key="cqi0", icon="mdi:speedometer", entity_category=EntityCategory.DIAGNOSTIC, ), "cqi1": HuaweiSensorEntityDescription( key="cqi1", - name="CQI 1", + translation_key="cqi1", icon="mdi:speedometer", ), "dl_mcs": HuaweiSensorEntityDescription( key="dl_mcs", - name="Downlink MCS", + translation_key="downlink_mcs", entity_category=EntityCategory.DIAGNOSTIC, ), "dlbandwidth": HuaweiSensorEntityDescription( key="dlbandwidth", - name="Downlink bandwidth", + translation_key="downlink_bandwidth", icon_fn=lambda x: bandwidth_icon((8, 15), x), entity_category=EntityCategory.DIAGNOSTIC, ), "dlfrequency": HuaweiSensorEntityDescription( key="dlfrequency", - name="Downlink frequency", + translation_key="downlink_frequency", device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), "earfcn": HuaweiSensorEntityDescription( key="earfcn", - name="EARFCN", + translation_key="earfcn", entity_category=EntityCategory.DIAGNOSTIC, ), "ecio": HuaweiSensorEntityDescription( key="ecio", - name="EC/IO", + translation_key="ecio", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/EC/IO icon_fn=lambda x: signal_icon((-20, -10, -6), x), @@ -228,18 +228,18 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "enodeb_id": HuaweiSensorEntityDescription( key="enodeb_id", - name="eNodeB ID", + translation_key="enodeb_id", entity_category=EntityCategory.DIAGNOSTIC, ), "lac": HuaweiSensorEntityDescription( key="lac", - name="LAC", + translation_key="lac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "ltedlfreq": HuaweiSensorEntityDescription( key="ltedlfreq", - name="LTE downlink frequency", + translation_key="lte_downlink_frequency", format_fn=format_freq_mhz, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, @@ -247,7 +247,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "lteulfreq": HuaweiSensorEntityDescription( key="lteulfreq", - name="LTE uplink frequency", + translation_key="lte_uplink_frequency", format_fn=format_freq_mhz, suggested_display_precision=0, device_class=SensorDeviceClass.FREQUENCY, @@ -255,7 +255,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "mode": HuaweiSensorEntityDescription( key="mode", - name="Mode", + translation_key="mode", format_fn=lambda x: ( {"0": "2G", "2": "3G", "7": "4G"}.get(x), None, @@ -271,29 +271,29 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "pci": HuaweiSensorEntityDescription( key="pci", - name="PCI", + translation_key="pci", icon="mdi:transmission-tower", entity_category=EntityCategory.DIAGNOSTIC, ), "plmn": HuaweiSensorEntityDescription( key="plmn", - name="PLMN", + translation_key="plmn", entity_category=EntityCategory.DIAGNOSTIC, ), "rac": HuaweiSensorEntityDescription( key="rac", - name="RAC", + translation_key="rac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "rrc_status": HuaweiSensorEntityDescription( key="rrc_status", - name="RRC status", + translation_key="rrc_status", entity_category=EntityCategory.DIAGNOSTIC, ), "rscp": HuaweiSensorEntityDescription( key="rscp", - name="RSCP", + translation_key="rscp", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://wiki.teltonika.lt/view/RSCP icon_fn=lambda x: signal_icon((-95, -85, -75), x), @@ -302,7 +302,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rsrp": HuaweiSensorEntityDescription( key="rsrp", - name="RSRP", + translation_key="rsrp", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrp.php icon_fn=lambda x: signal_icon((-110, -95, -80), x), @@ -312,7 +312,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rsrq": HuaweiSensorEntityDescription( key="rsrq", - name="RSRQ", + translation_key="rsrq", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/rsrq.php icon_fn=lambda x: signal_icon((-11, -8, -5), x), @@ -322,7 +322,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "rssi": HuaweiSensorEntityDescription( key="rssi", - name="RSSI", + translation_key="rssi", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # https://eyesaas.com/wi-fi-signal-strength/ icon_fn=lambda x: signal_icon((-80, -70, -60), x), @@ -332,7 +332,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "sinr": HuaweiSensorEntityDescription( key="sinr", - name="SINR", + translation_key="sinr", device_class=SensorDeviceClass.SIGNAL_STRENGTH, # http://www.lte-anbieter.info/technik/sinr.php icon_fn=lambda x: signal_icon((0, 5, 10), x), @@ -342,23 +342,23 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "tac": HuaweiSensorEntityDescription( key="tac", - name="TAC", + translation_key="tac", icon="mdi:map-marker", entity_category=EntityCategory.DIAGNOSTIC, ), "tdd": HuaweiSensorEntityDescription( key="tdd", - name="TDD", + translation_key="tdd", entity_category=EntityCategory.DIAGNOSTIC, ), "transmode": HuaweiSensorEntityDescription( key="transmode", - name="Transmission mode", + translation_key="transmission_mode", entity_category=EntityCategory.DIAGNOSTIC, ), "txpower": HuaweiSensorEntityDescription( key="txpower", - name="Transmit power", + translation_key="transmit_power", # The value we get from the API tends to consist of several, e.g. # PPusch:15dBm PPucch:2dBm PSrs:42dBm PPrach:1dBm # Present as SIGNAL_STRENGTH only if it was parsed to a number. @@ -372,18 +372,18 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "ul_mcs": HuaweiSensorEntityDescription( key="ul_mcs", - name="Uplink MCS", + translation_key="uplink_mcs", entity_category=EntityCategory.DIAGNOSTIC, ), "ulbandwidth": HuaweiSensorEntityDescription( key="ulbandwidth", - name="Uplink bandwidth", + translation_key="uplink_bandwidth", icon_fn=lambda x: bandwidth_icon((8, 15), x), entity_category=EntityCategory.DIAGNOSTIC, ), "ulfrequency": HuaweiSensorEntityDescription( key="ulfrequency", - name="Uplink frequency", + translation_key="uplink_frequency", device_class=SensorDeviceClass.FREQUENCY, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -399,7 +399,9 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), descriptions={ "UnreadMessage": HuaweiSensorEntityDescription( - key="UnreadMessage", name="SMS unread", icon="mdi:email-arrow-left" + key="UnreadMessage", + translation_key="sms_unread", + icon="mdi:email-arrow-left", ), }, ), @@ -410,7 +412,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "CurrentDayUsed": HuaweiSensorEntityDescription( key="CurrentDayUsed", - name="Current day transfer", + translation_key="current_day_transfer", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:arrow-up-down-bold", @@ -420,7 +422,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentMonthDownload": HuaweiSensorEntityDescription( key="CurrentMonthDownload", - name="Current month download", + translation_key="current_month_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -430,7 +432,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentMonthUpload": HuaweiSensorEntityDescription( key="CurrentMonthUpload", - name="Current month upload", + translation_key="current_month_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -448,7 +450,6 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "BatteryPercent": HuaweiSensorEntityDescription( key="BatteryPercent", - name="Battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -456,32 +457,32 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentWifiUser": HuaweiSensorEntityDescription( key="CurrentWifiUser", - name="WiFi clients connected", + translation_key="wifi_clients_connected", icon="mdi:wifi", state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryDns": HuaweiSensorEntityDescription( key="PrimaryDns", - name="Primary DNS server", + translation_key="primary_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "PrimaryIPv6Dns": HuaweiSensorEntityDescription( key="PrimaryIPv6Dns", - name="Primary IPv6 DNS server", + translation_key="primary_ipv6_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryDns": HuaweiSensorEntityDescription( key="SecondaryDns", - name="Secondary DNS server", + translation_key="secondary_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), "SecondaryIPv6Dns": HuaweiSensorEntityDescription( key="SecondaryIPv6Dns", - name="Secondary IPv6 DNS server", + translation_key="secondary_ipv6_dns_server", icon="mdi:ip", entity_category=EntityCategory.DIAGNOSTIC, ), @@ -492,14 +493,14 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "CurrentConnectTime": HuaweiSensorEntityDescription( key="CurrentConnectTime", - name="Current connection duration", + translation_key="current_connection_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, icon="mdi:timer-outline", ), "CurrentDownload": HuaweiSensorEntityDescription( key="CurrentDownload", - name="Current connection download", + translation_key="current_connection_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -507,7 +508,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentDownloadRate": HuaweiSensorEntityDescription( key="CurrentDownloadRate", - name="Current download rate", + translation_key="current_download_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:download", @@ -515,7 +516,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentUpload": HuaweiSensorEntityDescription( key="CurrentUpload", - name="Current connection upload", + translation_key="current_connection_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -523,7 +524,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "CurrentUploadRate": HuaweiSensorEntityDescription( key="CurrentUploadRate", - name="Current upload rate", + translation_key="current_upload_rate", native_unit_of_measurement=UnitOfDataRate.BYTES_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, icon="mdi:upload", @@ -531,7 +532,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalConnectTime": HuaweiSensorEntityDescription( key="TotalConnectTime", - name="Total connected duration", + translation_key="total_connected_duration", native_unit_of_measurement=UnitOfTime.SECONDS, device_class=SensorDeviceClass.DURATION, icon="mdi:timer-outline", @@ -539,7 +540,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalDownload": HuaweiSensorEntityDescription( key="TotalDownload", - name="Total download", + translation_key="total_download", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:download", @@ -547,7 +548,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { ), "TotalUpload": HuaweiSensorEntityDescription( key="TotalUpload", - name="Total upload", + translation_key="total_upload", native_unit_of_measurement=UnitOfInformation.BYTES, device_class=SensorDeviceClass.DATA_SIZE, icon="mdi:upload", @@ -563,17 +564,17 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "FullName": HuaweiSensorEntityDescription( key="FullName", - name="Operator name", + translation_key="operator_name", entity_category=EntityCategory.DIAGNOSTIC, ), "Numeric": HuaweiSensorEntityDescription( key="Numeric", - name="Operator code", + translation_key="operator_code", entity_category=EntityCategory.DIAGNOSTIC, ), "State": HuaweiSensorEntityDescription( key="State", - name="Operator search mode", + translation_key="operator_search_mode", format_fn=lambda x: ( {"0": "Auto", "1": "Manual"}.get(x), None, @@ -587,7 +588,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "NetworkMode": HuaweiSensorEntityDescription( key="NetworkMode", - name="Preferred mode", + translation_key="preferred_mode", format_fn=lambda x: ( { NetworkModeEnum.MODE_AUTO.value: "4G/3G/2G", @@ -611,62 +612,62 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { descriptions={ "LocalDeleted": HuaweiSensorEntityDescription( key="LocalDeleted", - name="SMS deleted (device)", + translation_key="sms_deleted_device", icon="mdi:email-minus", ), "LocalDraft": HuaweiSensorEntityDescription( key="LocalDraft", - name="SMS drafts (device)", + translation_key="sms_drafts_device", icon="mdi:email-arrow-right-outline", ), "LocalInbox": HuaweiSensorEntityDescription( key="LocalInbox", - name="SMS inbox (device)", + translation_key="sms_inbox_device", icon="mdi:email", ), "LocalMax": HuaweiSensorEntityDescription( key="LocalMax", - name="SMS capacity (device)", + translation_key="sms_capacity_device", icon="mdi:email", ), "LocalOutbox": HuaweiSensorEntityDescription( key="LocalOutbox", - name="SMS outbox (device)", + translation_key="sms_outbox_device", icon="mdi:email-arrow-right", ), "LocalUnread": HuaweiSensorEntityDescription( key="LocalUnread", - name="SMS unread (device)", + translation_key="sms_unread_device", icon="mdi:email-arrow-left", ), "SimDraft": HuaweiSensorEntityDescription( key="SimDraft", - name="SMS drafts (SIM)", + translation_key="sms_drafts_sim", icon="mdi:email-arrow-right-outline", ), "SimInbox": HuaweiSensorEntityDescription( key="SimInbox", - name="SMS inbox (SIM)", + translation_key="sms_inbox_sim", icon="mdi:email", ), "SimMax": HuaweiSensorEntityDescription( key="SimMax", - name="SMS capacity (SIM)", + translation_key="sms_capacity_sim", icon="mdi:email", ), "SimOutbox": HuaweiSensorEntityDescription( key="SimOutbox", - name="SMS outbox (SIM)", + translation_key="sms_outbox_sim", icon="mdi:email-arrow-right", ), "SimUnread": HuaweiSensorEntityDescription( key="SimUnread", - name="SMS unread (SIM)", + translation_key="sms_unread_sim", icon="mdi:email-arrow-left", ), "SimUsed": HuaweiSensorEntityDescription( key="SimUsed", - name="SMS messages (SIM)", + translation_key="sms_messages_sim", icon="mdi:email-arrow-left", ), }, diff --git a/homeassistant/components/huawei_lte/strings.json b/homeassistant/components/huawei_lte/strings.json index 41826dc6ae7..f188eb9e17b 100644 --- a/homeassistant/components/huawei_lte/strings.json +++ b/homeassistant/components/huawei_lte/strings.json @@ -49,6 +49,236 @@ } } }, + "entity": { + "binary_sensor": { + "mobile_connection": { + "name": "Mobile connection" + }, + "wifi_status": { + "name": "Wi-Fi status" + }, + "24ghz_wifi_status": { + "name": "2.4GHz Wi-Fi status" + }, + "5ghz_wifi_status": { + "name": "5GHz Wi-Fi status" + }, + "sms_storage_full": { + "name": "SMS storage full" + } + }, + "sensor": { + "uptime": { + "name": "Uptime" + }, + "wan_ip_address": { + "name": "WAN IP address" + }, + "wan_ipv6_address": { + "name": "WAN IPv6 address" + }, + "arfcn": { + "name": "ARFCN" + }, + "band": { + "name": "Band" + }, + "base_station_identity_code": { + "name": "Base station identity code" + }, + "cell_id": { + "name": "Cell ID" + }, + "cqi0": { + "name": "CQI 0" + }, + "cqi1": { + "name": "CQI 1" + }, + "downlink_mcs": { + "name": "Downlink MCS" + }, + "downlink_bandwidth": { + "name": "Downlink bandwidth" + }, + "downlink_frequency": { + "name": "Downlink frequency" + }, + "earfcn": { + "name": "EARFCN" + }, + "ecio": { + "name": "EC/IO" + }, + "enodeb_id": { + "name": "eNodeB ID" + }, + "lac": { + "name": "LAC" + }, + "lte_downlink_frequency": { + "name": "LTE downlink frequency" + }, + "lte_uplink_frequency": { + "name": "LTE uplink frequency" + }, + "pci": { + "name": "PCI" + }, + "plmn": { + "name": "PLMN" + }, + "rac": { + "name": "RAC" + }, + "rrc_status": { + "name": "RRC status" + }, + "rscp": { + "name": "RSCP" + }, + "rsrp": { + "name": "RSRP" + }, + "rsrq": { + "name": "RSRQ" + }, + "rssi": { + "name": "RSSI" + }, + "sinr": { + "name": "SINR" + }, + "tac": { + "name": "TAC" + }, + "tdd": { + "name": "TDD" + }, + "transmission_mode": { + "name": "Transmission mode" + }, + "transmit_power": { + "name": "Transmit power" + }, + "uplink_mcs": { + "name": "Uplink MCS" + }, + "uplink_bandwidth": { + "name": "Uplink bandwidth" + }, + "uplink_frequency": { + "name": "Uplink frequency" + }, + "sms_unread": { + "name": "SMS unread" + }, + "current_day_transfer": { + "name": "Current day transfer" + }, + "current_month_download": { + "name": "Current month download" + }, + "current_month_upload": { + "name": "Current month upload" + }, + "wifi_clients_connected": { + "name": "Wi-Fi clients connected" + }, + "primary_dns_server": { + "name": "Primary DNS server" + }, + "primary_ipv6_dns_server": { + "name": "Primary IPv6 DNS server" + }, + "secondary_dns_server": { + "name": "Secondary DNS server" + }, + "secondary_ipv6_dns_server": { + "name": "Secondary IPv6 DNS server" + }, + "current_connection_duration": { + "name": "Current connection duration" + }, + "current_connection_download": { + "name": "Current connection download" + }, + "current_download_rate": { + "name": "Current download rate" + }, + "current_connection_upload": { + "name": "Current connection upload" + }, + "current_upload_rate": { + "name": "Current upload rate" + }, + "total_connected_duration": { + "name": "Total connected duration" + }, + "total_download": { + "name": "Total download" + }, + "total_upload": { + "name": "Total upload" + }, + "operator_name": { + "name": "Operator name" + }, + "operator_code": { + "name": "Operator code" + }, + "operator_search_mode": { + "name": "Operator search mode" + }, + "preferred_mode": { + "name": "Preferred mode" + }, + "sms_deleted_device": { + "name": "SMS deleted (device)" + }, + "sms_drafts_device": { + "name": "SMS drafts (device)" + }, + "sms_inbox_device": { + "name": "SMS inbox (device)" + }, + "sms_capacity_device": { + "name": "SMS capacity (device)" + }, + "sms_outbox_device": { + "name": "SMS outbox (device)" + }, + "sms_unread_device": { + "name": "SMS unread (device)" + }, + "sms_drafts_sim": { + "name": "SMS drafts (SIM)" + }, + "sms_inbox_sim": { + "name": "SMS inbox (SIM)" + }, + "sms_capacity_sim": { + "name": "SMS capacity (SIM)" + }, + "sms_outbox_sim": { + "name": "SMS outbox (SIM)" + }, + "sms_unread_sim": { + "name": "SMS unread (SIM)" + }, + "sms_messages_sim": { + "name": "SMS messages (SIM)" + } + }, + "switch": { + "mobile_data": { + "name": "Mobile data" + }, + "wifi_guest_network": { + "name": "Wi-Fi guest network" + } + } + }, "services": { "clear_traffic_statistics": { "name": "Clear traffic statistics", diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index f75cf14e89b..eb9370a946f 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -92,7 +92,7 @@ class HuaweiLteBaseSwitch(HuaweiLteBaseEntityWithDevice, SwitchEntity): class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): """Huawei LTE mobile data switch device.""" - _attr_name: str = field(default="Mobile data", init=False) + _attr_translation_key: str = field(default="mobile_data", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" @@ -124,7 +124,7 @@ class HuaweiLteMobileDataSwitch(HuaweiLteBaseSwitch): class HuaweiLteWifiGuestNetworkSwitch(HuaweiLteBaseSwitch): """Huawei LTE WiFi guest network switch device.""" - _attr_name: str = field(default="WiFi guest network", init=False) + _attr_translation_key: str = field(default="wifi_guest_network", init=False) def __post_init__(self) -> None: """Initialize identifiers.""" diff --git a/tests/components/huawei_lte/test_switches.py b/tests/components/huawei_lte/test_switches.py index 733d26e5471..dee4def9596 100644 --- a/tests/components/huawei_lte/test_switches.py +++ b/tests/components/huawei_lte/test_switches.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry -SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wifi_guest_network" +SWITCH_WIFI_GUEST_NETWORK = "switch.lte_wi_fi_guest_network" def magic_client(multi_basic_settings_value: dict) -> MagicMock: From 5087f0056c6933b9bfef062a2417798c978dd436 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 8 Oct 2023 10:16:59 -0400 Subject: [PATCH 292/968] Adjust Calendar doc strings and comments (#101655) --- homeassistant/components/calendar/__init__.py | 4 ++-- homeassistant/components/calendar/trigger.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index f868f951646..639a56cd658 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -813,7 +813,7 @@ def _validate_timespan( This converts the input service arguments into a `start` and `end` date or date time. This exists because service calls use `start_date` and `start_date_time` whereas the - normal entity methods can take either a `datetim` or `date` as a single `start` argument. + normal entity methods can take either a `datetime` or `date` as a single `start` argument. It also handles the other service call variations like "in days" as well. """ @@ -849,7 +849,7 @@ async def async_create_event(entity: CalendarEntity, call: ServiceCall) -> None: async def async_list_events_service( calendar: CalendarEntity, service_call: ServiceCall ) -> ServiceResponse: - """List events on a calendar during a time drange.""" + """List events on a calendar during a time range.""" start = service_call.data.get(EVENT_START_DATETIME, dt_util.now()) if EVENT_DURATION in service_call.data: end = start + service_call.data[EVENT_DURATION] diff --git a/homeassistant/components/calendar/trigger.py b/homeassistant/components/calendar/trigger.py index f8a6014e261..073c41fc0df 100644 --- a/homeassistant/components/calendar/trigger.py +++ b/homeassistant/components/calendar/trigger.py @@ -75,15 +75,15 @@ class Timespan: This effectively gives us a cursor like interface for advancing through time using the interval as a hint. The returned span may have a - different interval than the one specified. For example, time span may + different interval than the one specified. For example, time span may be longer during a daylight saving time transition, or may extend due to - drift if the current interval is old. The returned time span is + drift if the current interval is old. The returned time span is adjacent and non-overlapping. """ return Timespan(self.end, max(self.end, now) + interval) def __str__(self) -> str: - """Return a string representing the half open interval timespan.""" + """Return a string representing the half open interval time span.""" return f"[{self.start}, {self.end})" @@ -118,7 +118,7 @@ def queued_event_fetcher( offset_timespan = timespan.with_offset(-1 * offset) active_events = await fetcher(offset_timespan) - # Determine the trigger eligibilty of events during this time span. + # Determine the trigger eligibility of events during this time span. # Example: For an EVENT_END trigger the event may start during this # time span, but need to be triggered later when the end happens. results = [] @@ -130,7 +130,7 @@ def queued_event_fetcher( results.append(QueuedCalendarEvent(trigger_time + offset, event)) _LOGGER.debug( - "Scan events @ %s%s found %s eligble of %s active", + "Scan events @ %s%s found %s eligible of %s active", offset_timespan, f" (offset={offset})" if offset else "", len(results), From 4a437e46aa3840aad9843bd2c130aef8d9bc2075 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sun, 8 Oct 2023 16:26:09 +0200 Subject: [PATCH 293/968] Update home-assistant/wheels to 2023.10.4 (#101656) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b3ae794b8fb..83f81b0cd4a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.3 + uses: home-assistant/wheels@2023.10.4 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -190,7 +190,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.3 + uses: home-assistant/wheels@2023.10.4 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -205,7 +205,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.3 + uses: home-assistant/wheels@2023.10.4 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.3 + uses: home-assistant/wheels@2023.10.4 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -233,7 +233,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.3 + uses: home-assistant/wheels@2023.10.4 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From 3c5772c1c6f232d055a76fa32b56a8233e50e913 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sun, 8 Oct 2023 17:01:00 +0200 Subject: [PATCH 294/968] Remove myself as codeowner for sonos and kodi (#101662) --- CODEOWNERS | 8 ++++---- homeassistant/components/kodi/manifest.json | 2 +- homeassistant/components/sonos/manifest.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b63526733b3..8dacaa7021e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -660,8 +660,8 @@ build.json @home-assistant/supervisor /tests/components/kmtronic/ @dgomes /homeassistant/components/knx/ @Julius2342 @farmio @marvin-w /tests/components/knx/ @Julius2342 @farmio @marvin-w -/homeassistant/components/kodi/ @OnFreund @cgtobi -/tests/components/kodi/ @OnFreund @cgtobi +/homeassistant/components/kodi/ @OnFreund +/tests/components/kodi/ @OnFreund /homeassistant/components/konnected/ @heythisisnate /tests/components/konnected/ @heythisisnate /homeassistant/components/kostal_plenticore/ @stegm @@ -1191,8 +1191,8 @@ build.json @home-assistant/supervisor /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn /tests/components/songpal/ @rytilahti @shenxn -/homeassistant/components/sonos/ @cgtobi @jjlawren -/tests/components/sonos/ @cgtobi @jjlawren +/homeassistant/components/sonos/ @jjlawren +/tests/components/sonos/ @jjlawren /homeassistant/components/soundtouch/ @kroimon /tests/components/soundtouch/ @kroimon /homeassistant/components/spaceapi/ @fabaff diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json index 16574844a01..708a15e0fc2 100644 --- a/homeassistant/components/kodi/manifest.json +++ b/homeassistant/components/kodi/manifest.json @@ -2,7 +2,7 @@ "domain": "kodi", "name": "Kodi", "after_dependencies": ["media_source"], - "codeowners": ["@OnFreund", "@cgtobi"], + "codeowners": ["@OnFreund"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/kodi", "iot_class": "local_push", diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index fce34bde80a..5d36da862ca 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -2,7 +2,7 @@ "domain": "sonos", "name": "Sonos", "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], - "codeowners": ["@cgtobi", "@jjlawren"], + "codeowners": ["@jjlawren"], "config_flow": true, "dependencies": ["ssdp"], "documentation": "https://www.home-assistant.io/integrations/sonos", From c48b724dde1887849f4464c6a27ffbfc1c55021c Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Sun, 8 Oct 2023 19:00:06 +0200 Subject: [PATCH 295/968] Make setup more resilient by raising ConfigNEntryNotReady on failure (#101654) Make setup more resilient by raising ConfigNEntryNotReady on connection failure --- homeassistant/components/loqed/__init__.py | 23 ++++++++++++++++------ tests/components/loqed/test_init.py | 17 ++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/loqed/__init__.py b/homeassistant/components/loqed/__init__.py index 3ee65f751ae..cc43baab1c8 100644 --- a/homeassistant/components/loqed/__init__.py +++ b/homeassistant/components/loqed/__init__.py @@ -1,14 +1,17 @@ """The loqed integration.""" from __future__ import annotations +import asyncio import logging import re +import aiohttp from loqedAPI import loqed from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -27,12 +30,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: apiclient = loqed.APIClient(websession, f"http://{host}") api = loqed.LoqedAPI(apiclient) - lock = await api.async_get_lock( - entry.data["lock_key_key"], - entry.data["bridge_key"], - int(entry.data["lock_key_local_id"]), - re.sub(r"LOQED-([a-f0-9]+)\.local", r"\1", entry.data["bridge_mdns_hostname"]), - ) + try: + lock = await api.async_get_lock( + entry.data["lock_key_key"], + entry.data["bridge_key"], + int(entry.data["lock_key_local_id"]), + re.sub( + r"LOQED-([a-f0-9]+)\.local", r"\1", entry.data["bridge_mdns_hostname"] + ), + ) + except ( + asyncio.TimeoutError, + aiohttp.ClientError, + ) as ex: + raise ConfigEntryNotReady(f"Unable to connect to bridge at {host}") from ex coordinator = LoqedDataCoordinator(hass, api, lock, entry) await coordinator.ensure_webhooks() diff --git a/tests/components/loqed/test_init.py b/tests/components/loqed/test_init.py index 057061f5915..47f53a1ad20 100644 --- a/tests/components/loqed/test_init.py +++ b/tests/components/loqed/test_init.py @@ -4,6 +4,7 @@ import json from typing import Any from unittest.mock import AsyncMock, patch +import aiohttp from loqedAPI import loqed from homeassistant.components.loqed.const import DOMAIN @@ -58,6 +59,22 @@ async def test_setup_webhook_in_bridge( lock.registerWebhook.assert_called_with(f"{get_url(hass)}/api/webhook/Webhook_id") +async def test_cannot_connect_to_bridge_will_retry( + hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock +): + """Test webhook setup in loqed bridge.""" + config: dict[str, Any] = {DOMAIN: {}} + config_entry.add_to_hass(hass) + + with patch( + "loqedAPI.loqed.LoqedAPI.async_get_lock", side_effect=aiohttp.ClientError + ): + await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + + async def test_setup_cloudhook_in_bridge( hass: HomeAssistant, config_entry: MockConfigEntry, lock: loqed.Lock ): From 6420cdb42b2f9d8714f55278d9f91e7f19becb3a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Oct 2023 07:08:04 -1000 Subject: [PATCH 296/968] Bump httpx to 0.25.0 and httpcore to 0.18.0 (#101635) --- homeassistant/package_constraints.txt | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- script/gen_requirements_all.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0a299d50239..5e657dde403 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20231005.0 home-assistant-intents==2023.10.2 -httpx==0.24.1 +httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 @@ -104,7 +104,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.7.1 h11==0.14.0 -httpcore==0.17.3 +httpcore==0.18.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/pyproject.toml b/pyproject.toml index 0bae2c942d7..a8c41d2912e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "ciso8601==2.3.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.24.1", + "httpx==0.25.0", "home-assistant-bluetooth==1.10.3", "ifaddr==0.2.0", "Jinja2==3.1.2", diff --git a/requirements.txt b/requirements.txt index 62c5ab5aede..3d64dbe69ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ awesomeversion==23.8.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 -httpx==0.24.1 +httpx==0.25.0 home-assistant-bluetooth==1.10.3 ifaddr==0.2.0 Jinja2==3.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7e9218b4cd9..78879424098 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -106,7 +106,7 @@ regex==2021.8.28 # requirements so we can directly link HA versions to these library versions. anyio==3.7.1 h11==0.14.0 -httpcore==0.17.3 +httpcore==0.18.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation From ba3fd4dee12536368352c3c402ae384941745f59 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 8 Oct 2023 13:39:56 -0400 Subject: [PATCH 297/968] Add Queue sensor to Radarr (#79723) --- homeassistant/components/radarr/__init__.py | 4 +- homeassistant/components/radarr/const.py | 1 + .../components/radarr/coordinator.py | 19 ++- homeassistant/components/radarr/sensor.py | 10 ++ homeassistant/components/radarr/strings.json | 3 + tests/components/radarr/__init__.py | 26 ++- tests/components/radarr/fixtures/queue.json | 153 ++++++++++++++++++ .../radarr/fixtures/single-movie.json | 116 ------------- tests/components/radarr/test_init.py | 19 ++- tests/components/radarr/test_sensor.py | 20 ++- 10 files changed, 223 insertions(+), 148 deletions(-) create mode 100644 tests/components/radarr/fixtures/queue.json delete mode 100644 tests/components/radarr/fixtures/single-movie.json diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py index c7f31a999e7..39258e2f787 100644 --- a/homeassistant/components/radarr/__init__.py +++ b/homeassistant/components/radarr/__init__.py @@ -25,6 +25,7 @@ from .coordinator import ( DiskSpaceDataUpdateCoordinator, HealthDataUpdateCoordinator, MoviesDataUpdateCoordinator, + QueueDataUpdateCoordinator, RadarrDataUpdateCoordinator, StatusDataUpdateCoordinator, T, @@ -45,10 +46,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: session=async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]), ) coordinators: dict[str, RadarrDataUpdateCoordinator[Any]] = { - "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), "disk_space": DiskSpaceDataUpdateCoordinator(hass, host_configuration, radarr), "health": HealthDataUpdateCoordinator(hass, host_configuration, radarr), "movie": MoviesDataUpdateCoordinator(hass, host_configuration, radarr), + "queue": QueueDataUpdateCoordinator(hass, host_configuration, radarr), + "status": StatusDataUpdateCoordinator(hass, host_configuration, radarr), } for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/radarr/const.py b/homeassistant/components/radarr/const.py index b77e134ca34..37388dd51ef 100644 --- a/homeassistant/components/radarr/const.py +++ b/homeassistant/components/radarr/const.py @@ -5,6 +5,7 @@ from typing import Final DOMAIN: Final = "radarr" # Defaults +DEFAULT_MAX_RECORDS = 20 DEFAULT_NAME = "Radarr" DEFAULT_URL = "http://127.0.0.1:7878" diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index c318d662028..bd41810bfb8 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -3,7 +3,7 @@ from __future__ import annotations from abc import ABC, abstractmethod from datetime import timedelta -from typing import Generic, TypeVar +from typing import Generic, TypeVar, cast from aiopyarr import Health, RadarrMovie, RootFolder, SystemStatus, exceptions from aiopyarr.models.host_configuration import PyArrHostConfiguration @@ -14,7 +14,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, LOGGER +from .const import DEFAULT_MAX_RECORDS, DOMAIN, LOGGER T = TypeVar("T", bound=SystemStatus | list[RootFolder] | list[Health] | int) @@ -90,7 +90,14 @@ class MoviesDataUpdateCoordinator(RadarrDataUpdateCoordinator[int]): async def _fetch_data(self) -> int: """Fetch the movies data.""" - movies = await self.api_client.async_get_movies() - if isinstance(movies, RadarrMovie): - return 1 - return len(movies) + return len(cast(list[RadarrMovie], await self.api_client.async_get_movies())) + + +class QueueDataUpdateCoordinator(RadarrDataUpdateCoordinator): + """Queue update coordinator.""" + + async def _fetch_data(self) -> int: + """Fetch the movies in queue.""" + return ( + await self.api_client.async_get_queue(page_size=DEFAULT_MAX_RECORDS) + ).totalRecords diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py index 803b6de44a4..ab4315b269a 100644 --- a/homeassistant/components/radarr/sensor.py +++ b/homeassistant/components/radarr/sensor.py @@ -13,6 +13,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation @@ -82,6 +83,15 @@ SENSOR_TYPES: dict[str, RadarrSensorEntityDescription[Any]] = { entity_registry_enabled_default=False, value_fn=lambda data, _: data, ), + "queue": RadarrSensorEntityDescription[int]( + key="queue", + translation_key="queue", + native_unit_of_measurement="Movies", + icon="mdi:download", + entity_registry_enabled_default=False, + state_class=SensorStateClass.TOTAL, + value_fn=lambda data, _: data, + ), "status": RadarrSensorEntityDescription[SystemStatus]( key="start_time", translation_key="start_time", diff --git a/homeassistant/components/radarr/strings.json b/homeassistant/components/radarr/strings.json index 5cd7bcfc449..ec1baf6ffd8 100644 --- a/homeassistant/components/radarr/strings.json +++ b/homeassistant/components/radarr/strings.json @@ -45,6 +45,9 @@ "movies": { "name": "Movies" }, + "queue": { + "name": "Queue" + }, "start_time": { "name": "Start time" } diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py index 069eeabe8d8..f7bdf232c9e 100644 --- a/tests/components/radarr/__init__.py +++ b/tests/components/radarr/__init__.py @@ -76,6 +76,11 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"{url}/api/v3/queue", + text=load_fixture("radarr/queue.json"), + headers={"Content-Type": CONTENT_TYPE_JSON}, + ) root_folder_fixture = "rootfolder-linux" if windows: @@ -90,13 +95,9 @@ def mock_connection( headers={"Content-Type": CONTENT_TYPE_JSON}, ) - movie_fixture = "movie" - if single_return: - movie_fixture = f"single-{movie_fixture}" - aioclient_mock.get( f"{url}/api/v3/movie", - text=load_fixture(f"radarr/{movie_fixture}.json"), + text=load_fixture("radarr/movie.json"), headers={"Content-Type": CONTENT_TYPE_JSON}, ) @@ -114,10 +115,11 @@ def mock_connection_invalid_auth( url: str = URL, ) -> None: """Mock radarr invalid auth errors.""" - aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.UNAUTHORIZED) aioclient_mock.get(f"{url}/api/v3/rootfolder", status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.get(f"{url}/api/v3/system/status", status=HTTPStatus.UNAUTHORIZED) def mock_connection_server_error( @@ -125,14 +127,15 @@ def mock_connection_server_error( url: str = URL, ) -> None: """Mock radarr server errors.""" - aioclient_mock.get( - f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR - ) aioclient_mock.get(f"{url}/api/v3/command", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get(f"{url}/api/v3/movie", status=HTTPStatus.INTERNAL_SERVER_ERROR) + aioclient_mock.get(f"{url}/api/v3/queue", status=HTTPStatus.INTERNAL_SERVER_ERROR) aioclient_mock.get( f"{url}/api/v3/rootfolder", status=HTTPStatus.INTERNAL_SERVER_ERROR ) + aioclient_mock.get( + f"{url}/api/v3/system/status", status=HTTPStatus.INTERNAL_SERVER_ERROR + ) async def setup_integration( @@ -185,11 +188,6 @@ def patch_async_setup_entry(return_value=True): ) -def patch_radarr(): - """Patch radarr api.""" - return patch("homeassistant.components.radarr.RadarrClient.async_get_system_status") - - def create_entry(hass: HomeAssistant) -> MockConfigEntry: """Create Radarr entry in Home Assistant.""" entry = MockConfigEntry( diff --git a/tests/components/radarr/fixtures/queue.json b/tests/components/radarr/fixtures/queue.json new file mode 100644 index 00000000000..804f1fd3a21 --- /dev/null +++ b/tests/components/radarr/fixtures/queue.json @@ -0,0 +1,153 @@ +{ + "page": 1, + "pageSize": 10, + "sortKey": "timeleft", + "sortDirection": "ascending", + "totalRecords": 2, + "records": [ + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test", + "sizeleft": 0, + "timeleft": "string", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + }, + { + "movieId": 0, + "languages": [ + { + "id": 0, + "name": "string" + } + ], + "quality": { + "quality": { + "id": 0, + "name": "string", + "source": "string", + "resolution": 0, + "modifier": "string" + }, + "revision": { + "version": 0, + "real": 0, + "isRepack": true + } + }, + "customFormats": [ + { + "id": 0, + "name": "string", + "includeCustomFormatWhenRenaming": true, + "specifications": [ + { + "name": "string", + "implementation": "string", + "implementationName": "string", + "infoLink": "string", + "negate": true, + "required": true, + "fields": [ + { + "order": 0, + "name": "string", + "label": "string", + "helpText": "string", + "value": "string", + "type": "string", + "advanced": true + } + ] + } + ] + } + ], + "size": 0, + "title": "test2", + "sizeleft": 1000000, + "timeleft": "00:00:00", + "estimatedCompletionTime": "2020-01-21T00:01:59Z", + "status": "string", + "trackedDownloadStatus": "string", + "trackedDownloadState": "downloading", + "statusMessages": [ + { + "title": "string", + "messages": ["string"] + } + ], + "errorMessage": "string", + "downloadId": "string", + "protocol": "unknown", + "downloadClient": "string", + "indexer": "string", + "outputPath": "string", + "id": 0 + } + ] +} diff --git a/tests/components/radarr/fixtures/single-movie.json b/tests/components/radarr/fixtures/single-movie.json deleted file mode 100644 index db9e720d285..00000000000 --- a/tests/components/radarr/fixtures/single-movie.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "id": 0, - "title": "string", - "originalTitle": "string", - "alternateTitles": [ - { - "sourceType": "tmdb", - "movieId": 1, - "title": "string", - "sourceId": 0, - "votes": 0, - "voteCount": 0, - "language": { - "id": 1, - "name": "English" - }, - "id": 1 - } - ], - "sortTitle": "string", - "sizeOnDisk": 0, - "overview": "string", - "inCinemas": "2020-11-06T00:00:00Z", - "physicalRelease": "2019-03-19T00:00:00Z", - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ], - "website": "string", - "year": 0, - "hasFile": true, - "youTubeTrailerId": "string", - "studio": "string", - "path": "string", - "rootFolderPath": "string", - "qualityProfileId": 0, - "monitored": true, - "minimumAvailability": "announced", - "isAvailable": true, - "folderName": "string", - "runtime": 0, - "cleanTitle": "string", - "imdbId": "string", - "tmdbId": 0, - "titleSlug": "string", - "certification": "string", - "genres": ["string"], - "tags": [0], - "added": "2018-12-28T05:56:49Z", - "ratings": { - "votes": 0, - "value": 0 - }, - "movieFile": { - "movieId": 0, - "relativePath": "string", - "path": "string", - "size": 916662234, - "dateAdded": "2020-11-26T02:00:35Z", - "indexerFlags": 1, - "quality": { - "quality": { - "id": 14, - "name": "WEBRip-720p", - "source": "webrip", - "resolution": 720, - "modifier": "none" - }, - "revision": { - "version": 1, - "real": 0, - "isRepack": false - } - }, - "mediaInfo": { - "audioBitrate": 0, - "audioChannels": 2, - "audioCodec": "AAC", - "audioLanguages": "", - "audioStreamCount": 1, - "videoBitDepth": 8, - "videoBitrate": 1000000, - "videoCodec": "x264", - "videoFps": 25.0, - "resolution": "1280x534", - "runTime": "1:49:06", - "scanType": "Progressive", - "subtitles": "" - }, - "originalFilePath": "string", - "qualityCutoffNotMet": true, - "languages": [ - { - "id": 26, - "name": "Hindi" - } - ], - "edition": "", - "id": 35361 - }, - "collection": { - "name": "string", - "tmdbId": 0, - "images": [ - { - "coverType": "poster", - "url": "string", - "remoteUrl": "string" - } - ] - }, - "status": "deleted" -} diff --git a/tests/components/radarr/test_init.py b/tests/components/radarr/test_init.py index 6b602c8c4d1..f16e5895633 100644 --- a/tests/components/radarr/test_init.py +++ b/tests/components/radarr/test_init.py @@ -1,12 +1,10 @@ """Test Radarr integration.""" -from aiopyarr import exceptions - from homeassistant.components.radarr.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from . import create_entry, patch_radarr, setup_integration +from . import create_entry, mock_connection_invalid_auth, setup_integration from tests.test_util.aiohttp import AiohttpClientMocker @@ -33,15 +31,16 @@ async def test_async_setup_entry_not_ready( assert not hass.data.get(DOMAIN) -async def test_async_setup_entry_auth_failed(hass: HomeAssistant) -> None: +async def test_async_setup_entry_auth_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: """Test that it throws ConfigEntryAuthFailed when authentication fails.""" entry = create_entry(hass) - with patch_radarr() as radarrmock: - radarrmock.side_effect = exceptions.ArrAuthenticationException - await hass.config_entries.async_setup(entry.entry_id) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - assert entry.state == ConfigEntryState.SETUP_ERROR - assert not hass.data.get(DOMAIN) + mock_connection_invalid_auth(aioclient_mock) + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) async def test_device_info( diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py index f4f863d9bb6..90ab683037b 100644 --- a/tests/components/radarr/test_sensor.py +++ b/tests/components/radarr/test_sensor.py @@ -1,7 +1,11 @@ """The tests for Radarr sensor platform.""" import pytest -from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -55,3 +59,17 @@ async def test_sensors( state = hass.states.get("sensor.mock_title_start_time") assert state.state == "2020-09-01T23:50:20+00:00" assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + state = hass.states.get("sensor.mock_title_queue") + assert state.state == "2" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "Movies" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL + + +async def test_windows( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for successfully setting up the Radarr platform on Windows.""" + await setup_integration(hass, aioclient_mock, windows=True) + + state = hass.states.get("sensor.mock_title_disk_space_tv") + assert state.state == "263.10" From 1b11062b278a125fbacdf0a565d6bbf7957e69a9 Mon Sep 17 00:00:00 2001 From: Nicolas van de Walle Date: Sun, 8 Oct 2023 19:40:42 +0200 Subject: [PATCH 298/968] Improved debugging for "Failed to set state" (#101657) --- homeassistant/helpers/entity.py | 4 +++- tests/helpers/test_entity.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 542841f7f7c..f0a05f7aded 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -907,7 +907,9 @@ class Entity(ABC): self._state_info, ) except InvalidStateError: - _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + _LOGGER.exception( + "Failed to set state for %s, fall back to %s", entity_id, STATE_UNKNOWN + ) hass.states.async_set( entity_id, STATE_UNKNOWN, {}, self.force_update, self._context ) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index ff9ad99435f..572a2afaa5d 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -1500,7 +1500,7 @@ async def test_invalid_state( assert ( "homeassistant.helpers.entity", logging.ERROR, - f"Failed to set state, fall back to {STATE_UNKNOWN}", + f"Failed to set state for test.test, fall back to {STATE_UNKNOWN}", ) in caplog.record_tuples ent._attr_state = "x" * 255 From c6ed022cced0b1a023c515e27d2c1ee0f4bde8f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 8 Oct 2023 07:43:00 -1000 Subject: [PATCH 299/968] Fix compiling missing statistics losing rows (#101616) --- .../components/recorder/statistics.py | 133 ++++++++---------- homeassistant/components/sensor/recorder.py | 27 +--- tests/components/energy/test_sensor.py | 18 ++- tests/components/recorder/test_statistics.py | 70 +++++---- .../components/recorder/test_websocket_api.py | 13 +- .../sensor/test_recorder_missing_stats.py | 124 ++++++++++++++++ 6 files changed, 252 insertions(+), 133 deletions(-) create mode 100644 tests/components/sensor/test_recorder_missing_stats.py diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0ea16e09df4..a6fe7ddb22f 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -526,7 +526,7 @@ def _compile_statistics( ): continue compiled: PlatformCompiledStatistics = platform_compile_statistics( - instance.hass, start, end + instance.hass, session, start, end ) _LOGGER.debug( "Statistics for %s during %s-%s: %s", @@ -1871,7 +1871,7 @@ def get_latest_short_term_statistics_by_ids( return list( cast( Sequence[Row], - execute_stmt_lambda_element(session, stmt, orm_rows=False), + execute_stmt_lambda_element(session, stmt), ) ) @@ -1887,75 +1887,69 @@ def _latest_short_term_statistics_by_ids_stmt( ) -def get_latest_short_term_statistics( +def get_latest_short_term_statistics_with_session( hass: HomeAssistant, + session: Session, statistic_ids: set[str], types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], metadata: dict[str, tuple[int, StatisticMetaData]] | None = None, ) -> dict[str, list[StatisticsRow]]: - """Return the latest short term statistics for a list of statistic_ids.""" - with session_scope(hass=hass, read_only=True) as session: - # Fetch metadata for the given statistic_ids - if not metadata: - metadata = get_instance(hass).statistics_meta_manager.get_many( - session, statistic_ids=statistic_ids - ) - if not metadata: - return {} - metadata_ids = set( - _extract_metadata_and_discard_impossible_columns(metadata, types) + """Return the latest short term statistics for a list of statistic_ids with a session.""" + # Fetch metadata for the given statistic_ids + if not metadata: + metadata = get_instance(hass).statistics_meta_manager.get_many( + session, statistic_ids=statistic_ids ) - run_cache = get_short_term_statistics_run_cache(hass) - # Try to find the latest short term statistics ids for the metadata_ids - # from the run cache first if we have it. If the run cache references - # a non-existent id because of a purge, we will detect it missing in the - # next step and run a query to re-populate the cache. - stats: list[Row] = [] - if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids): - stats = get_latest_short_term_statistics_by_ids( - session, metadata_id_to_id.values() - ) - # If we are missing some metadata_ids in the run cache, we need run a query - # to populate the cache for each metadata_id, and then run another query - # to get the latest short term statistics for the missing metadata_ids. - if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and ( - found_latest_ids := { - latest_id - for metadata_id in missing_metadata_ids - if ( - latest_id := cache_latest_short_term_statistic_id_for_metadata_id( - # orm_rows=False is used here because we are in - # a read-only session, and there will never be - # any pending inserts in the session. - run_cache, - session, - metadata_id, - orm_rows=False, - ) + if not metadata: + return {} + metadata_ids = set( + _extract_metadata_and_discard_impossible_columns(metadata, types) + ) + run_cache = get_short_term_statistics_run_cache(hass) + # Try to find the latest short term statistics ids for the metadata_ids + # from the run cache first if we have it. If the run cache references + # a non-existent id because of a purge, we will detect it missing in the + # next step and run a query to re-populate the cache. + stats: list[Row] = [] + if metadata_id_to_id := run_cache.get_latest_ids(metadata_ids): + stats = get_latest_short_term_statistics_by_ids( + session, metadata_id_to_id.values() + ) + # If we are missing some metadata_ids in the run cache, we need run a query + # to populate the cache for each metadata_id, and then run another query + # to get the latest short term statistics for the missing metadata_ids. + if (missing_metadata_ids := metadata_ids - set(metadata_id_to_id)) and ( + found_latest_ids := { + latest_id + for metadata_id in missing_metadata_ids + if ( + latest_id := cache_latest_short_term_statistic_id_for_metadata_id( + run_cache, + session, + metadata_id, ) - is not None - } - ): - stats.extend( - get_latest_short_term_statistics_by_ids(session, found_latest_ids) ) + is not None + } + ): + stats.extend(get_latest_short_term_statistics_by_ids(session, found_latest_ids)) - if not stats: - return {} + if not stats: + return {} - # Return statistics combined with metadata - return _sorted_statistics_to_dict( - hass, - session, - stats, - statistic_ids, - metadata, - False, - StatisticsShortTerm, - None, - None, - types, - ) + # Return statistics combined with metadata + return _sorted_statistics_to_dict( + hass, + session, + stats, + statistic_ids, + metadata, + False, + StatisticsShortTerm, + None, + None, + types, + ) def _generate_statistics_at_time_stmt( @@ -2316,14 +2310,8 @@ def _import_statistics_with_session( # We just inserted new short term statistics, so we need to update the # ShortTermStatisticsRunCache with the latest id for the metadata_id run_cache = get_short_term_statistics_run_cache(instance.hass) - # - # Because we are in the same session and we want to read rows - # that have not been flushed yet, we need to pass orm_rows=True - # to cache_latest_short_term_statistic_id_for_metadata_id - # to ensure that it gets the rows that were just inserted - # cache_latest_short_term_statistic_id_for_metadata_id( - run_cache, session, metadata_id, orm_rows=True + run_cache, session, metadata_id ) return True @@ -2341,7 +2329,6 @@ def cache_latest_short_term_statistic_id_for_metadata_id( run_cache: ShortTermStatisticsRunCache, session: Session, metadata_id: int, - orm_rows: bool, ) -> int | None: """Cache the latest short term statistic for a given metadata_id. @@ -2352,13 +2339,7 @@ def cache_latest_short_term_statistic_id_for_metadata_id( if latest := cast( Sequence[Row], execute_stmt_lambda_element( - session, - _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id), - orm_rows=orm_rows - # _import_statistics_with_session needs to be able - # to read back the rows it just inserted without - # a flush so we have to pass orm_rows so we get - # back the latest data. + session, _find_latest_short_term_statistic_for_metadata_id_stmt(metadata_id) ), ): id_: int = latest[0].id diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index cb5a81d6b84..3cf1dc975ec 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -16,7 +16,6 @@ from homeassistant.components.recorder import ( get_instance, history, statistics, - util as recorder_util, ) from homeassistant.components.recorder.models import ( StatisticData, @@ -374,27 +373,7 @@ def _timestamp_to_isoformat_or_none(timestamp: float | None) -> str | None: return dt_util.utc_from_timestamp(timestamp).isoformat() -def compile_statistics( - hass: HomeAssistant, start: datetime.datetime, end: datetime.datetime -) -> statistics.PlatformCompiledStatistics: - """Compile statistics for all entities during start-end. - - Note: This will query the database and must not be run in the event loop - """ - # There is already an active session when this code is called since - # it is called from the recorder statistics. We need to make sure - # this session never gets committed since it would be out of sync - # with the recorder statistics session so we mark it as read only. - # - # If we ever need to write to the database from this function we - # will need to refactor the recorder statistics to use a single - # session. - with recorder_util.session_scope(hass=hass, read_only=True) as session: - compiled = _compile_statistics(hass, session, start, end) - return compiled - - -def _compile_statistics( # noqa: C901 +def compile_statistics( # noqa: C901 hass: HomeAssistant, session: Session, start: datetime.datetime, @@ -471,8 +450,8 @@ def _compile_statistics( # noqa: C901 if "sum" in wanted_statistics[entity_id]: to_query.add(entity_id) - last_stats = statistics.get_latest_short_term_statistics( - hass, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas + last_stats = statistics.get_latest_short_term_statistics_with_session( + hass, session, to_query, {"last_reset", "state", "sum"}, metadata=old_metadatas ) for ( # pylint: disable=too-many-nested-blocks entity_id, diff --git a/tests/components/energy/test_sensor.py b/tests/components/energy/test_sensor.py index f5fea153380..bf1513507f8 100644 --- a/tests/components/energy/test_sensor.py +++ b/tests/components/energy/test_sensor.py @@ -6,6 +6,7 @@ from typing import Any import pytest from homeassistant.components.energy import data +from homeassistant.components.recorder.util import session_scope from homeassistant.components.sensor import ( ATTR_LAST_RESET, ATTR_STATE_CLASS, @@ -155,7 +156,10 @@ async def test_cost_sensor_price_entity_total_increasing( """Test energy cost price from total_increasing type sensor entity.""" def _compile_statistics(_): - return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats + with session_scope(hass=hass) as session: + return compile_statistics( + hass, session, now, now + timedelta(seconds=1) + ).platform_stats energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, @@ -365,9 +369,10 @@ async def test_cost_sensor_price_entity_total( """Test energy cost price from total type sensor entity.""" def _compile_statistics(_): - return compile_statistics( - hass, now, now + timedelta(seconds=0.17) - ).platform_stats + with session_scope(hass=hass) as session: + return compile_statistics( + hass, session, now, now + timedelta(seconds=0.17) + ).platform_stats energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, @@ -579,7 +584,10 @@ async def test_cost_sensor_price_entity_total_no_reset( """Test energy cost price from total type sensor entity with no last_reset.""" def _compile_statistics(_): - return compile_statistics(hass, now, now + timedelta(seconds=1)).platform_stats + with session_scope(hass=hass) as session: + return compile_statistics( + hass, session, now, now + timedelta(seconds=1) + ).platform_stats energy_attributes = { ATTR_UNIT_OF_MEASUREMENT: UnitOfEnergy.KILO_WATT_HOUR, diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index e56b2b83274..03dc7b84caa 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -22,7 +22,7 @@ from homeassistant.components.recorder.statistics import ( async_import_statistics, get_last_short_term_statistics, get_last_statistics, - get_latest_short_term_statistics, + get_latest_short_term_statistics_with_session, get_metadata, get_short_term_statistics_run_cache, list_statistic_ids, @@ -71,9 +71,13 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) assert_dict_of_states_equal_without_context_and_last_changed(states, hist) # Should not fail if there is nothing there yet - stats = get_latest_short_term_statistics( - hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) assert stats == {} for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): @@ -172,28 +176,38 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) ) assert stats == {"sensor.test1": [expected_2]} - stats = get_latest_short_term_statistics( - hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) assert stats == {"sensor.test1": [expected_2]} # Now wipe the latest_short_term_statistics_ids table and test again # to make sure we can rebuild the missing data run_cache = get_short_term_statistics_run_cache(instance.hass) run_cache._latest_id_by_metadata_id = {} - stats = get_latest_short_term_statistics( - hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) assert stats == {"sensor.test1": [expected_2]} metadata = get_metadata(hass, statistic_ids={"sensor.test1"}) - stats = get_latest_short_term_statistics( - hass, - {"sensor.test1"}, - {"last_reset", "max", "mean", "min", "state", "sum"}, - metadata=metadata, - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + metadata=metadata, + ) assert stats == {"sensor.test1": [expected_2]} stats = get_last_short_term_statistics( @@ -225,10 +239,14 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) instance.get_session().query(StatisticsShortTerm).delete() # Should not fail there is nothing in the table - stats = get_latest_short_term_statistics( - hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) - assert stats == {} + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) + assert stats == {} # Delete again, and manually wipe the cache since we deleted all the data instance.get_session().query(StatisticsShortTerm).delete() @@ -236,9 +254,13 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) run_cache._latest_id_by_metadata_id = {} # And test again to make sure there is no data - stats = get_latest_short_term_statistics( - hass, {"sensor.test1"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test1"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) assert stats == {} @@ -259,7 +281,7 @@ def mock_sensor_statistics(): "stat": {"start": start}, } - def get_fake_stats(_hass, start, _end): + def get_fake_stats(_hass, session, start, _end): return statistics.PlatformCompiledStatistics( [ sensor_stats("sensor.test1", start), diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 969fdd63ae5..b371d69fe5f 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -14,11 +14,12 @@ from homeassistant.components.recorder.db_schema import Statistics, StatisticsSh from homeassistant.components.recorder.statistics import ( async_add_external_statistics, get_last_statistics, - get_latest_short_term_statistics, + get_latest_short_term_statistics_with_session, get_metadata, get_short_term_statistics_run_cache, list_statistic_ids, ) +from homeassistant.components.recorder.util import session_scope from homeassistant.components.recorder.websocket_api import UNIT_SCHEMA from homeassistant.components.sensor import UNIT_CONVERTERS from homeassistant.core import HomeAssistant @@ -636,9 +637,13 @@ async def test_statistic_during_period( "change": (imported_stats_5min[-1]["sum"] - imported_stats_5min[0]["sum"]) * 1000, } - stats = get_latest_short_term_statistics( - hass, {"sensor.test"}, {"last_reset", "max", "mean", "min", "state", "sum"} - ) + with session_scope(hass=hass, read_only=True) as session: + stats = get_latest_short_term_statistics_with_session( + hass, + session, + {"sensor.test"}, + {"last_reset", "max", "mean", "min", "state", "sum"}, + ) start = imported_stats_5min[-1]["start"].timestamp() end = start + (5 * 60) assert stats == { diff --git a/tests/components/sensor/test_recorder_missing_stats.py b/tests/components/sensor/test_recorder_missing_stats.py new file mode 100644 index 00000000000..f6f6445a0fb --- /dev/null +++ b/tests/components/sensor/test_recorder_missing_stats.py @@ -0,0 +1,124 @@ +"""The tests for sensor recorder platform can catch up.""" +from datetime import datetime, timedelta +from pathlib import Path +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.components.recorder.statistics import ( + get_latest_short_term_statistics_with_session, + statistics_during_period, +) +from homeassistant.components.recorder.util import session_scope +from homeassistant.core import CoreState, HomeAssistant +from homeassistant.helpers import recorder as recorder_helper +from homeassistant.setup import setup_component +import homeassistant.util.dt as dt_util + +from tests.common import get_test_home_assistant +from tests.components.recorder.common import do_adhoc_statistics, wait_recording_done + +POWER_SENSOR_ATTRIBUTES = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", +} + + +@pytest.fixture(autouse=True) +def disable_db_issue_creation(): + """Disable the creation of the database issue.""" + with patch( + "homeassistant.components.recorder.util._async_create_mariadb_range_index_regression_issue" + ): + yield + + +@pytest.mark.timeout(25) +def test_compile_missing_statistics( + freezer: FrozenDateTimeFactory, recorder_db_url: str, tmp_path: Path +) -> None: + """Test compile missing statistics.""" + if recorder_db_url == "sqlite://": + # On-disk database because we need to stop and start hass + # and have it persist. + recorder_db_url = "sqlite:///" + str(tmp_path / "pytest.db") + config = { + "db_url": recorder_db_url, + } + three_days_ago = datetime(2021, 1, 1, 0, 0, 0, tzinfo=dt_util.UTC) + start_time = three_days_ago + timedelta(days=3) + freezer.move_to(three_days_ago) + hass: HomeAssistant = get_test_home_assistant() + hass.state = CoreState.not_running + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "sensor", {}) + setup_component(hass, "recorder", {"recorder": config}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + + hass.states.set("sensor.test1", "0", POWER_SENSOR_ATTRIBUTES) + wait_recording_done(hass) + + two_days_ago = three_days_ago + timedelta(days=1) + freezer.move_to(two_days_ago) + do_adhoc_statistics(hass, start=two_days_ago) + wait_recording_done(hass) + with session_scope(hass=hass, read_only=True) as session: + latest = get_latest_short_term_statistics_with_session( + hass, session, {"sensor.test1"}, {"state", "sum"} + ) + latest_stat = latest["sensor.test1"][0] + assert latest_stat["start"] == 1609545600.0 + assert latest_stat["end"] == 1609545600.0 + 300 + count = 1 + past_time = two_days_ago + while past_time <= start_time: + freezer.move_to(past_time) + hass.states.set("sensor.test1", str(count), POWER_SENSOR_ATTRIBUTES) + past_time += timedelta(minutes=5) + count += 1 + + wait_recording_done(hass) + + states = get_significant_states(hass, three_days_ago, past_time, ["sensor.test1"]) + assert len(states["sensor.test1"]) == 577 + + hass.stop() + freezer.move_to(start_time) + hass: HomeAssistant = get_test_home_assistant() + hass.state = CoreState.not_running + recorder_helper.async_initialize_recorder(hass) + setup_component(hass, "sensor", {}) + hass.states.set("sensor.test1", "0", POWER_SENSOR_ATTRIBUTES) + setup_component(hass, "recorder", {"recorder": config}) + hass.start() + wait_recording_done(hass) + wait_recording_done(hass) + with session_scope(hass=hass, read_only=True) as session: + latest = get_latest_short_term_statistics_with_session( + hass, session, {"sensor.test1"}, {"state", "sum", "max", "mean", "min"} + ) + latest_stat = latest["sensor.test1"][0] + assert latest_stat["start"] == 1609718100.0 + assert latest_stat["end"] == 1609718100.0 + 300 + assert latest_stat["mean"] == 576.0 + assert latest_stat["min"] == 575.0 + assert latest_stat["max"] == 576.0 + stats = statistics_during_period( + hass, + two_days_ago, + start_time, + units={"energy": "kWh"}, + statistic_ids={"sensor.test1"}, + period="hour", + types={"mean"}, + ) + # Make sure we have 48 hours of statistics + assert len(stats["sensor.test1"]) == 48 + # Make sure the last mean is 570.5 + assert stats["sensor.test1"][-1]["mean"] == 570.5 + hass.stop() From 78535b99df0b6a13d85ee9befc5a17fcb30f2084 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 8 Oct 2023 20:07:04 +0200 Subject: [PATCH 300/968] Move nina coordinator and entity to their own file (#101610) --- homeassistant/components/nina/__init__.py | 135 +---------------- .../components/nina/binary_sensor.py | 2 +- homeassistant/components/nina/coordinator.py | 138 ++++++++++++++++++ 3 files changed, 140 insertions(+), 135 deletions(-) create mode 100644 homeassistant/components/nina/coordinator.py diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 4ac2518ffb6..435ea288aa7 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -1,21 +1,11 @@ """The Nina integration.""" from __future__ import annotations -import asyncio -from dataclasses import dataclass -import re -from typing import Any - -from pynina import ApiError, Nina - from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( - _LOGGER, ALL_MATCH_REGEX, CONF_AREA_FILTER, CONF_FILTER_CORONA, @@ -23,8 +13,8 @@ from .const import ( CONF_REGIONS, DOMAIN, NO_MATCH_REGEX, - SCAN_INTERVAL, ) +from .coordinator import NINADataUpdateCoordinator PLATFORMS: list[str] = [Platform.BINARY_SENSOR] @@ -74,126 +64,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) - - -@dataclass -class NinaWarningData: - """Class to hold the warning data.""" - - id: str - headline: str - description: str - sender: str - severity: str - recommended_actions: str - affected_areas: str - sent: str - start: str - expires: str - is_valid: bool - - -class NINADataUpdateCoordinator( - DataUpdateCoordinator[dict[str, list[NinaWarningData]]] -): - """Class to manage fetching NINA data API.""" - - def __init__( - self, - hass: HomeAssistant, - regions: dict[str, str], - headline_filter: str, - area_filter: str, - ) -> None: - """Initialize.""" - self._regions: dict[str, str] = regions - self._nina: Nina = Nina(async_get_clientsession(hass)) - self.headline_filter: str = headline_filter - self.area_filter: str = area_filter - - for region in regions: - self._nina.addRegion(region) - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - - async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: - """Update data.""" - async with asyncio.timeout(10): - try: - await self._nina.update() - except ApiError as err: - raise UpdateFailed(err) from err - return self._parse_data() - - @staticmethod - def _remove_duplicate_warnings( - warnings: dict[str, list[Any]] - ) -> dict[str, list[Any]]: - """Remove warnings with the same title and expires timestamp in a region.""" - all_filtered_warnings: dict[str, list[Any]] = {} - - for region_id, raw_warnings in warnings.items(): - filtered_warnings: list[Any] = [] - processed_details: list[tuple[str, str]] = [] - - for raw_warn in raw_warnings: - if (raw_warn.headline, raw_warn.expires) in processed_details: - continue - - processed_details.append((raw_warn.headline, raw_warn.expires)) - - filtered_warnings.append(raw_warn) - - all_filtered_warnings[region_id] = filtered_warnings - - return all_filtered_warnings - - def _parse_data(self) -> dict[str, list[NinaWarningData]]: - """Parse warning data.""" - - return_data: dict[str, list[NinaWarningData]] = {} - - for region_id, raw_warnings in self._remove_duplicate_warnings( - self._nina.warnings - ).items(): - warnings_for_regions: list[NinaWarningData] = [] - - for raw_warn in raw_warnings: - if re.search( - self.headline_filter, raw_warn.headline, flags=re.IGNORECASE - ): - _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" - ) - continue - - affected_areas_string: str = ", ".join( - [str(area) for area in raw_warn.affected_areas] - ) - - if not re.search( - self.area_filter, affected_areas_string, flags=re.IGNORECASE - ): - _LOGGER.debug( - f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" - ) - continue - - warning_data: NinaWarningData = NinaWarningData( - raw_warn.id, - raw_warn.headline, - raw_warn.description, - raw_warn.sender, - raw_warn.severity, - " ".join([str(action) for action in raw_warn.recommended_actions]), - affected_areas_string, - raw_warn.sent or "", - raw_warn.start or "", - raw_warn.expires or "", - raw_warn.isValid(), - ) - warnings_for_regions.append(warning_data) - - return_data[region_id] = warnings_for_regions - - return return_data diff --git a/homeassistant/components/nina/binary_sensor.py b/homeassistant/components/nina/binary_sensor.py index 19f802f1cec..568869ca402 100644 --- a/homeassistant/components/nina/binary_sensor.py +++ b/homeassistant/components/nina/binary_sensor.py @@ -12,7 +12,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import NINADataUpdateCoordinator from .const import ( ATTR_AFFECTED_AREAS, ATTR_DESCRIPTION, @@ -28,6 +27,7 @@ from .const import ( CONF_REGIONS, DOMAIN, ) +from .coordinator import NINADataUpdateCoordinator async def async_setup_entry( diff --git a/homeassistant/components/nina/coordinator.py b/homeassistant/components/nina/coordinator.py new file mode 100644 index 00000000000..eb5c7a7e506 --- /dev/null +++ b/homeassistant/components/nina/coordinator.py @@ -0,0 +1,138 @@ +"""DataUpdateCoordinator for the nina integration.""" +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +import re +from typing import Any + +from pynina import ApiError, Nina + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import _LOGGER, DOMAIN, SCAN_INTERVAL + + +@dataclass +class NinaWarningData: + """Class to hold the warning data.""" + + id: str + headline: str + description: str + sender: str + severity: str + recommended_actions: str + affected_areas: str + sent: str + start: str + expires: str + is_valid: bool + + +class NINADataUpdateCoordinator( + DataUpdateCoordinator[dict[str, list[NinaWarningData]]] +): + """Class to manage fetching NINA data API.""" + + def __init__( + self, + hass: HomeAssistant, + regions: dict[str, str], + headline_filter: str, + area_filter: str, + ) -> None: + """Initialize.""" + self._regions: dict[str, str] = regions + self._nina: Nina = Nina(async_get_clientsession(hass)) + self.headline_filter: str = headline_filter + self.area_filter: str = area_filter + + for region in regions: + self._nina.addRegion(region) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) + + async def _async_update_data(self) -> dict[str, list[NinaWarningData]]: + """Update data.""" + async with asyncio.timeout(10): + try: + await self._nina.update() + except ApiError as err: + raise UpdateFailed(err) from err + return self._parse_data() + + @staticmethod + def _remove_duplicate_warnings( + warnings: dict[str, list[Any]] + ) -> dict[str, list[Any]]: + """Remove warnings with the same title and expires timestamp in a region.""" + all_filtered_warnings: dict[str, list[Any]] = {} + + for region_id, raw_warnings in warnings.items(): + filtered_warnings: list[Any] = [] + processed_details: list[tuple[str, str]] = [] + + for raw_warn in raw_warnings: + if (raw_warn.headline, raw_warn.expires) in processed_details: + continue + + processed_details.append((raw_warn.headline, raw_warn.expires)) + + filtered_warnings.append(raw_warn) + + all_filtered_warnings[region_id] = filtered_warnings + + return all_filtered_warnings + + def _parse_data(self) -> dict[str, list[NinaWarningData]]: + """Parse warning data.""" + + return_data: dict[str, list[NinaWarningData]] = {} + + for region_id, raw_warnings in self._remove_duplicate_warnings( + self._nina.warnings + ).items(): + warnings_for_regions: list[NinaWarningData] = [] + + for raw_warn in raw_warnings: + if re.search( + self.headline_filter, raw_warn.headline, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by headline filter ({self.headline_filter}) with headline: {raw_warn.headline}" + ) + continue + + affected_areas_string: str = ", ".join( + [str(area) for area in raw_warn.affected_areas] + ) + + if not re.search( + self.area_filter, affected_areas_string, flags=re.IGNORECASE + ): + _LOGGER.debug( + f"Ignore warning ({raw_warn.id}) by area filter ({self.area_filter}) with area: {affected_areas_string}" + ) + continue + + warning_data: NinaWarningData = NinaWarningData( + raw_warn.id, + raw_warn.headline, + raw_warn.description, + raw_warn.sender, + raw_warn.severity, + " ".join([str(action) for action in raw_warn.recommended_actions]), + affected_areas_string, + raw_warn.sent or "", + raw_warn.start or "", + raw_warn.expires or "", + raw_warn.isValid(), + ) + warnings_for_regions.append(warning_data) + + return_data[region_id] = warnings_for_regions + + return return_data From db0c5bbbea733ba2514e00598a5bb8bb60f86233 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 8 Oct 2023 20:57:14 +0200 Subject: [PATCH 301/968] Fix mqtt sensor or binary_sensor state not saved after expiry (#101670) Fix mqtt sensor state not saved after expire --- homeassistant/components/mqtt/binary_sensor.py | 2 +- homeassistant/components/mqtt/sensor.py | 4 +++- tests/components/mqtt/test_binary_sensor.py | 13 ++++++++++++- tests/components/mqtt/test_sensor.py | 13 ++++++++++++- 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index c0f4cc7786e..7eb444b046a 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -180,7 +180,7 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): @callback @log_messages(self.hass, self.entity_id) - @write_state_on_attr_change(self, {"_attr_is_on"}) + @write_state_on_attr_change(self, {"_attr_is_on", "_expired"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT state message.""" # auto-expire enabled? diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 05db22a8e62..0f73b93f1de 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -277,7 +277,9 @@ class MqttSensor(MqttEntity, RestoreSensor): ) @callback - @write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"}) + @write_state_on_attr_change( + self, {"_attr_native_value", "_attr_last_reset", "_expired"} + ) @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ea9c8072290..e7a4c9ab1aa 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -123,7 +123,6 @@ async def test_setting_sensor_value_expires_availability_topic( "name": "test", "state_topic": "test-topic", "expire_after": 4, - "force_update": True, } } } @@ -200,6 +199,18 @@ async def expires_helper(hass: HomeAssistant) -> None: state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE + # Send the last message again + # Time jump 0.5s + now += timedelta(seconds=0.5) + freezer.move_to(now) + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "test-topic", "OFF") + await hass.async_block_till_done() + + # Value was updated correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass: HomeAssistant, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index bc75492a03e..06967b7f8a8 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -339,7 +339,6 @@ async def test_setting_sensor_value_expires_availability_topic( "state_topic": "test-topic", "unit_of_measurement": "fav unit", "expire_after": "4", - "force_update": True, } } } @@ -413,6 +412,18 @@ async def expires_helper(hass: HomeAssistant) -> None: state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE + # Send the last message again + # Time jump 0.5s + now += timedelta(seconds=0.5) + freezer.move_to(now) + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "test-topic", "101") + await hass.async_block_till_done() + + # Value was updated correctly. + state = hass.states.get("sensor.test") + assert state.state == "101" + @pytest.mark.parametrize( "hass_config", From fb215479d47b25dc9c034b2c90243c8e20d08024 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 8 Oct 2023 22:01:26 +0200 Subject: [PATCH 302/968] Add fibaro event platform (#101636) --- .coveragerc | 1 + homeassistant/components/fibaro/__init__.py | 28 ++++++++- homeassistant/components/fibaro/event.py | 68 +++++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/fibaro/event.py diff --git a/.coveragerc b/.coveragerc index 8f9ad53ef61..478454c18e8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -376,6 +376,7 @@ omit = homeassistant/components/fibaro/binary_sensor.py homeassistant/components/fibaro/climate.py homeassistant/components/fibaro/cover.py + homeassistant/components/fibaro/event.py homeassistant/components/fibaro/light.py homeassistant/components/fibaro/lock.py homeassistant/components/fibaro/sensor.py diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 364a97b3f6a..0272c620b99 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections import defaultdict -from collections.abc import Mapping +from collections.abc import Callable, Mapping import logging from typing import Any @@ -10,6 +10,7 @@ from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel from pyfibaro.fibaro_room import RoomModel from pyfibaro.fibaro_scene import SceneModel +from pyfibaro.fibaro_state_resolver import FibaroEvent, FibaroStateResolver from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -46,6 +47,7 @@ PLATFORMS = [ Platform.SENSOR, Platform.LOCK, Platform.SWITCH, + Platform.EVENT, ] FIBARO_TYPEMAP = { @@ -95,6 +97,8 @@ class FibaroController: # All scenes self._scenes: list[SceneModel] = [] self._callbacks: dict[int, list[Any]] = {} # Update value callbacks by deviceId + # Event callbacks by device id + self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub self.hub_software_version: str @@ -178,11 +182,31 @@ class FibaroController: for callback in self._callbacks[item]: callback() + resolver = FibaroStateResolver(state) + for event in resolver.get_events(): + fibaro_id = event.fibaro_id + if ( + event.event_type.lower() == "centralsceneevent" + and fibaro_id in self._event_callbacks + ): + for callback in self._event_callbacks[fibaro_id]: + callback(event) + def register(self, device_id: int, callback: Any) -> None: """Register device with a callback for updates.""" self._callbacks.setdefault(device_id, []) self._callbacks[device_id].append(callback) + def register_event( + self, device_id: int, callback: Callable[[FibaroEvent], None] + ) -> None: + """Register device with a callback for central scene events. + + The callback receives one parameter with the event. + """ + self._event_callbacks.setdefault(device_id, []) + self._event_callbacks[device_id].append(callback) + def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" return [ @@ -229,6 +253,8 @@ class FibaroController: platform = Platform.COVER elif "secure" in device.actions: platform = Platform.LOCK + elif device.has_central_scene_event: + platform = Platform.EVENT elif device.value.has_value: if device.value.is_bool_value: platform = Platform.BINARY_SENSOR diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py new file mode 100644 index 00000000000..33e4161087c --- /dev/null +++ b/homeassistant/components/fibaro/event.py @@ -0,0 +1,68 @@ +"""Support for Fibaro event entities.""" +from __future__ import annotations + +from pyfibaro.fibaro_device import DeviceModel, SceneEvent +from pyfibaro.fibaro_state_resolver import FibaroEvent + +from homeassistant.components.event import ( + ENTITY_ID_FORMAT, + EventDeviceClass, + EventEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import FibaroController, FibaroDevice +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Fibaro event entities.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + + entities = [] + for device in controller.fibaro_devices[Platform.EVENT]: + for scene_event in device.central_scene_event: + # Each scene event represents a button on a device + entities.append(FibaroEventEntity(device, scene_event)) + + async_add_entities(entities, True) + + +class FibaroEventEntity(FibaroDevice, EventEntity): + """Representation of a Fibaro Event Entity.""" + + def __init__(self, fibaro_device: DeviceModel, scene_event: SceneEvent) -> None: + """Initialize the Fibaro device.""" + super().__init__(fibaro_device) + self.entity_id = ENTITY_ID_FORMAT.format( + f"{self.ha_id}_button_{scene_event.key_id}" + ) + + self._button = scene_event.key_id + + self._attr_name = f"{fibaro_device.friendly_name} Button {scene_event.key_id}" + self._attr_device_class = EventDeviceClass.BUTTON + self._attr_event_types = scene_event.key_event_types + self._attr_unique_id = f"{fibaro_device.unique_id_str}.{scene_event.key_id}" + + async def async_added_to_hass(self) -> None: + """Call when entity is added to hass.""" + await super().async_added_to_hass() + + # Register event callback + self.controller.register_event( + self.fibaro_device.fibaro_id, self._event_callback + ) + + @callback + def _event_callback(self, event: FibaroEvent) -> None: + if event.key_id == self._button: + self._trigger_event(event.key_event_type) + self.schedule_update_ha_state() From 6d1876394ef9eceee38c2cf9082f45d37218be07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Sun, 8 Oct 2023 21:03:22 +0100 Subject: [PATCH 303/968] Rediscover Idasen Desk to allow re-setup (#101672) --- homeassistant/components/idasen_desk/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 5fd23ba47e0..04b3ef22e1b 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -90,5 +90,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) await data.desk.disconnect() + bluetooth.async_rediscover_address(hass, data.address) return unload_ok From d78ee96e2a862da167bc99f35186ec78eff93cf6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 8 Oct 2023 23:12:59 -0700 Subject: [PATCH 304/968] Update fitbit device fetch to use a data update coordinator (#101619) * Add a fitbit device update coordinator * Remove unnecessary debug output * Update comments * Update fitbit coordinator exception handling and test coverage * Handle reauth failures in other sensors * Fix scope changes after rebase. --- homeassistant/components/fitbit/__init__.py | 14 +- homeassistant/components/fitbit/api.py | 4 +- homeassistant/components/fitbit/const.py | 25 +- .../components/fitbit/coordinator.py | 48 ++++ homeassistant/components/fitbit/model.py | 41 +++ homeassistant/components/fitbit/sensor.py | 240 +++++++++--------- tests/components/fitbit/test_init.py | 49 ++++ tests/components/fitbit/test_sensor.py | 100 +++++++- 8 files changed, 389 insertions(+), 132 deletions(-) create mode 100644 homeassistant/components/fitbit/coordinator.py diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py index acf3014fb33..40ea9fb1152 100644 --- a/homeassistant/components/fitbit/__init__.py +++ b/homeassistant/components/fitbit/__init__.py @@ -8,8 +8,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from . import api -from .const import DOMAIN +from .const import DOMAIN, FitbitScope +from .coordinator import FitbitData, FitbitDeviceCoordinator from .exceptions import FitbitApiException, FitbitAuthException +from .model import config_from_entry_data PLATFORMS: list[Platform] = [Platform.SENSOR] @@ -34,7 +36,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FitbitApiException as err: raise ConfigEntryNotReady from err - hass.data[DOMAIN][entry.entry_id] = fitbit_api + fitbit_config = config_from_entry_data(entry.data) + coordinator: FitbitDeviceCoordinator | None = None + if fitbit_config.is_allowed_resource(FitbitScope.DEVICE, "devices/battery"): + coordinator = FitbitDeviceCoordinator(hass, fitbit_api) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = FitbitData( + api=fitbit_api, device_coordinator=coordinator + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/fitbit/api.py b/homeassistant/components/fitbit/api.py index dab64724e1c..ceb619c4385 100644 --- a/homeassistant/components/fitbit/api.py +++ b/homeassistant/components/fitbit/api.py @@ -134,10 +134,10 @@ class FitbitApi(ABC): return await self._hass.async_add_executor_job(func) except HTTPUnauthorized as err: _LOGGER.debug("Unauthorized error from fitbit API: %s", err) - raise FitbitAuthException from err + raise FitbitAuthException("Authentication error from fitbit API") from err except HTTPException as err: _LOGGER.debug("Error from fitbit API: %s", err) - raise FitbitApiException from err + raise FitbitApiException("Error from fitbit API") from err class OAuthFitbitApi(FitbitApi): diff --git a/homeassistant/components/fitbit/const.py b/homeassistant/components/fitbit/const.py index 9c77ea79a4f..45b81b3919e 100644 --- a/homeassistant/components/fitbit/const.py +++ b/homeassistant/components/fitbit/const.py @@ -67,14 +67,21 @@ class FitbitUnitSystem(StrEnum): """Use United Kingdom units.""" +CONF_SCOPE: Final = "scope" + + +class FitbitScope(StrEnum): + """OAuth scopes for fitbit.""" + + ACTIVITY = "activity" + HEART_RATE = "heartrate" + NUTRITION = "nutrition" + PROFILE = "profile" + DEVICE = "settings" + SLEEP = "sleep" + WEIGHT = "weight" + + OAUTH2_AUTHORIZE = "https://www.fitbit.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.fitbit.com/oauth2/token" -OAUTH_SCOPES = [ - "activity", - "heartrate", - "nutrition", - "profile", - "settings", - "sleep", - "weight", -] +OAUTH_SCOPES = [scope.value for scope in FitbitScope] diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py new file mode 100644 index 00000000000..5c156955f90 --- /dev/null +++ b/homeassistant/components/fitbit/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for fetching data from fitbit API.""" + +import asyncio +from dataclasses import dataclass +import datetime +import logging +from typing import Final + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import FitbitApi +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class FitbitDeviceCoordinator(DataUpdateCoordinator): + """Coordinator for fetching fitbit devices from the API.""" + + def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: + """Initialize FitbitDeviceCoordinator.""" + super().__init__(hass, _LOGGER, name="Fitbit", update_interval=UPDATE_INTERVAL) + self._api = api + + async def _async_update_data(self) -> dict[str, FitbitDevice]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + try: + devices = await self._api.async_get_devices() + except FitbitAuthException as err: + raise ConfigEntryAuthFailed(err) from err + except FitbitApiException as err: + raise UpdateFailed(err) from err + return {device.id: device for device in devices} + + +@dataclass +class FitbitData: + """Config Entry global data.""" + + api: FitbitApi + device_coordinator: FitbitDeviceCoordinator | None diff --git a/homeassistant/components/fitbit/model.py b/homeassistant/components/fitbit/model.py index 3d321d8dd01..38b1d0bb786 100644 --- a/homeassistant/components/fitbit/model.py +++ b/homeassistant/components/fitbit/model.py @@ -1,6 +1,10 @@ """Data representation for fitbit API responses.""" +from collections.abc import Mapping from dataclasses import dataclass +from typing import Any + +from .const import CONF_CLOCK_FORMAT, CONF_MONITORED_RESOURCES, FitbitScope @dataclass @@ -35,3 +39,40 @@ class FitbitDevice: type: str """The type of the device such as TRACKER or SCALE.""" + + +@dataclass +class FitbitConfig: + """Information from the fitbit ConfigEntry data.""" + + clock_format: str | None + monitored_resources: set[str] | None + scopes: set[FitbitScope] + + def is_explicit_enable(self, key: str) -> bool: + """Determine if entity is enabled by default.""" + if self.monitored_resources is not None: + return key in self.monitored_resources + return False + + def is_allowed_resource(self, scope: FitbitScope | None, key: str) -> bool: + """Determine if an entity is allowed to be created.""" + if self.is_explicit_enable(key): + return True + return scope in self.scopes + + +def config_from_entry_data(data: Mapping[str, Any]) -> FitbitConfig: + """Parse the integration config entry into a FitbitConfig.""" + + clock_format = data.get(CONF_CLOCK_FORMAT) + + # Originally entities were configured explicitly from yaml config. Newer + # configurations will infer which entities to enable based on the allowed + # scopes the user selected during OAuth. When creating entities based on + # scopes, some entities are disabled by default. + monitored_resources = data.get(CONF_MONITORED_RESOURCES) + fitbit_scopes: set[FitbitScope] = set({}) + if scopes := data["token"].get("scope"): + fitbit_scopes = set({FitbitScope(scope) for scope in scopes.split(" ")}) + return FitbitConfig(clock_format, monitored_resources, fitbit_scopes) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 51b1b64a391..17bd21544e0 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -35,13 +35,14 @@ from homeassistant.const import ( UnitOfTime, UnitOfVolume, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.json import load_json_object from .api import FitbitApi @@ -58,10 +59,12 @@ from .const import ( DOMAIN, FITBIT_CONFIG_FILE, FITBIT_DEFAULT_RESOURCES, + FitbitScope, FitbitUnitSystem, ) -from .exceptions import FitbitApiException -from .model import FitbitDevice +from .coordinator import FitbitData, FitbitDeviceCoordinator +from .exceptions import FitbitApiException, FitbitAuthException +from .model import FitbitDevice, config_from_entry_data _LOGGER: Final = logging.getLogger(__name__) @@ -137,7 +140,7 @@ class FitbitSensorEntityDescription(SensorEntityDescription): unit_type: str | None = None value_fn: Callable[[dict[str, Any]], Any] = _default_value_fn unit_fn: Callable[[FitbitUnitSystem], str | None] = lambda x: None - scope: str | None = None + scope: FitbitScope | None = None FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( @@ -146,7 +149,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -155,7 +158,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -163,7 +166,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Calories BMR", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -175,7 +178,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -184,7 +187,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -193,7 +196,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Floors", native_unit_of_measurement="floors", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -203,7 +206,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="bpm", icon="mdi:heart-pulse", value_fn=lambda result: int(result["value"]["restingHeartRate"]), - scope="heartrate", + scope=FitbitScope.HEART_RATE, state_class=SensorStateClass.MEASUREMENT, ), FitbitSensorEntityDescription( @@ -212,7 +215,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -222,7 +225,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -232,7 +235,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -242,7 +245,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -251,7 +254,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Steps", native_unit_of_measurement="steps", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, state_class=SensorStateClass.TOTAL_INCREASING, ), FitbitSensorEntityDescription( @@ -259,7 +262,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Activity Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -269,7 +272,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Calories", native_unit_of_measurement="cal", icon="mdi:fire", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -281,7 +284,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.DISTANCE, value_fn=_distance_value_fn, unit_fn=_distance_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -292,7 +295,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:walk", device_class=SensorDeviceClass.DISTANCE, unit_fn=_elevation_unit, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, @@ -302,7 +305,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Floors", native_unit_of_measurement="floors", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -313,7 +316,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -324,7 +327,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:walk", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -335,7 +338,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:seat-recline-normal", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -346,7 +349,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:run", device_class=SensorDeviceClass.DURATION, - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -356,7 +359,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( name="Tracker Steps", native_unit_of_measurement="steps", icon="mdi:walk", - scope="activity", + scope=FitbitScope.ACTIVITY, entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, @@ -368,7 +371,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, - scope="weight", + scope=FitbitScope.WEIGHT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -379,7 +382,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:human", state_class=SensorStateClass.MEASUREMENT, value_fn=_body_value_fn, - scope="weight", + scope=FitbitScope.WEIGHT, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -391,14 +394,14 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.WEIGHT, value_fn=_body_value_fn, unit_fn=_weight_unit, - scope="weight", + scope=FitbitScope.WEIGHT, ), FitbitSensorEntityDescription( key="sleep/awakeningsCount", name="Awakenings Count", native_unit_of_measurement="times awaken", icon="mdi:sleep", - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -408,7 +411,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=PERCENTAGE, icon="mdi:sleep", state_class=SensorStateClass.MEASUREMENT, - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( @@ -417,7 +420,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -427,7 +430,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -437,7 +440,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -447,7 +450,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -457,7 +460,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement=UnitOfTime.MINUTES, icon="mdi:hotel", device_class=SensorDeviceClass.DURATION, - scope="sleep", + scope=FitbitScope.SLEEP, state_class=SensorStateClass.TOTAL_INCREASING, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -467,7 +470,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( native_unit_of_measurement="cal", icon="mdi:food-apple", state_class=SensorStateClass.TOTAL_INCREASING, - scope="nutrition", + scope=FitbitScope.NUTRITION, entity_category=EntityCategory.DIAGNOSTIC, ), FitbitSensorEntityDescription( @@ -476,7 +479,7 @@ FITBIT_RESOURCES_LIST: Final[tuple[FitbitSensorEntityDescription, ...]] = ( icon="mdi:cup-water", unit_fn=_water_unit, state_class=SensorStateClass.TOTAL_INCREASING, - scope="nutrition", + scope=FitbitScope.NUTRITION, entity_category=EntityCategory.DIAGNOSTIC, ), ) @@ -486,7 +489,7 @@ SLEEP_START_TIME = FitbitSensorEntityDescription( key="sleep/startTime", name="Sleep Start Time", icon="mdi:clock", - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ) SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( @@ -494,7 +497,7 @@ SLEEP_START_TIME_12HR = FitbitSensorEntityDescription( name="Sleep Start Time", icon="mdi:clock", value_fn=_clock_format_12h, - scope="sleep", + scope=FitbitScope.SLEEP, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -502,7 +505,7 @@ FITBIT_RESOURCE_BATTERY = FitbitSensorEntityDescription( key="devices/battery", name="Battery", icon="mdi:battery", - scope="settings", + scope=FitbitScope.DEVICE, entity_category=EntityCategory.DIAGNOSTIC, ) @@ -614,41 +617,34 @@ async def async_setup_entry( ) -> None: """Set up the Fitbit sensor platform.""" - api: FitbitApi = hass.data[DOMAIN][entry.entry_id] + data: FitbitData = hass.data[DOMAIN][entry.entry_id] + api = data.api # Note: This will only be one rpc since it will cache the user profile (user_profile, unit_system) = await asyncio.gather( api.async_get_user_profile(), api.async_get_unit_system() ) - clock_format = entry.data.get(CONF_CLOCK_FORMAT) - - # Originally entities were configured explicitly from yaml config. Newer - # configurations will infer which entities to enable based on the allowed - # scopes the user selected during OAuth. When creating entities based on - # scopes, some entities are disabled by default. - monitored_resources = entry.data.get(CONF_MONITORED_RESOURCES) - scopes = entry.data["token"].get("scope", "").split(" ") + fitbit_config = config_from_entry_data(entry.data) def is_explicit_enable(description: FitbitSensorEntityDescription) -> bool: """Determine if entity is enabled by default.""" - if monitored_resources is not None: - return description.key in monitored_resources - return False + return fitbit_config.is_explicit_enable(description.key) def is_allowed_resource(description: FitbitSensorEntityDescription) -> bool: """Determine if an entity is allowed to be created.""" - if is_explicit_enable(description): - return True - return description.scope in scopes + return fitbit_config.is_allowed_resource(description.scope, description.key) resource_list = [ *FITBIT_RESOURCES_LIST, - SLEEP_START_TIME_12HR if clock_format == "12H" else SLEEP_START_TIME, + SLEEP_START_TIME_12HR + if fitbit_config.clock_format == "12H" + else SLEEP_START_TIME, ] entities = [ FitbitSensor( + entry, api, user_profile.encoded_id, description, @@ -658,22 +654,20 @@ async def async_setup_entry( for description in resource_list if is_allowed_resource(description) ] - if is_allowed_resource(FITBIT_RESOURCE_BATTERY): - devices = await api.async_get_devices() - entities.extend( - [ - FitbitSensor( - api, - user_profile.encoded_id, - FITBIT_RESOURCE_BATTERY, - device=device, - enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), - ) - for device in devices - ] - ) async_add_entities(entities, True) + if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): + async_add_entities( + FitbitBatterySensor( + data.device_coordinator, + user_profile.encoded_id, + FITBIT_RESOURCE_BATTERY, + device=device, + enable_default_override=is_explicit_enable(FITBIT_RESOURCE_BATTERY), + ) + for device in data.device_coordinator.data.values() + ) + class FitbitSensor(SensorEntity): """Implementation of a Fitbit sensor.""" @@ -683,22 +677,19 @@ class FitbitSensor(SensorEntity): def __init__( self, + config_entry: ConfigEntry, api: FitbitApi, user_profile_id: str, description: FitbitSensorEntityDescription, - device: FitbitDevice | None = None, - units: str | None = None, - enable_default_override: bool = False, + units: str | None, + enable_default_override: bool, ) -> None: """Initialize the Fitbit sensor.""" + self.config_entry = config_entry self.entity_description = description self.api = api - self.device = device self._attr_unique_id = f"{user_profile_id}_{description.key}" - if device is not None: - self._attr_name = f"{device.device_version} Battery" - self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" if units is not None: self._attr_native_unit_of_measurement = units @@ -706,50 +697,71 @@ class FitbitSensor(SensorEntity): if enable_default_override: self._attr_entity_registry_enabled_default = True + async def async_update(self) -> None: + """Get the latest data from the Fitbit API and update the states.""" + try: + result = await self.api.async_get_latest_time_series( + self.entity_description.key + ) + except FitbitAuthException: + self._attr_available = False + self.config_entry.async_start_reauth(self.hass) + except FitbitApiException: + self._attr_available = False + else: + self._attr_available = True + self._attr_native_value = self.entity_description.value_fn(result) + + +class FitbitBatterySensor(CoordinatorEntity, SensorEntity): + """Implementation of a Fitbit sensor.""" + + entity_description: FitbitSensorEntityDescription + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: FitbitDeviceCoordinator, + user_profile_id: str, + description: FitbitSensorEntityDescription, + device: FitbitDevice, + enable_default_override: bool, + ) -> None: + """Initialize the Fitbit sensor.""" + super().__init__(coordinator) + self.entity_description = description + self.device = device + self._attr_unique_id = f"{user_profile_id}_{description.key}" + if device is not None: + self._attr_name = f"{device.device_version} Battery" + self._attr_unique_id = f"{self._attr_unique_id}_{device.id}" + + if enable_default_override: + self._attr_entity_registry_enabled_default = True + @property def icon(self) -> str | None: """Icon to use in the frontend, if any.""" - if ( - self.entity_description.key == "devices/battery" - and self.device is not None - and (battery_level := BATTERY_LEVELS.get(self.device.battery)) is not None - ): + if battery_level := BATTERY_LEVELS.get(self.device.battery): return icon_for_battery_level(battery_level=battery_level) return self.entity_description.icon @property def extra_state_attributes(self) -> dict[str, str | None]: """Return the state attributes.""" - attrs: dict[str, str | None] = {} + return { + "model": self.device.device_version, + "type": self.device.type.lower() if self.device.type is not None else None, + } - if self.device is not None: - attrs["model"] = self.device.device_version - device_type = self.device.type - attrs["type"] = device_type.lower() if device_type is not None else None + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() - return attrs - - async def async_update(self) -> None: - """Get the latest data from the Fitbit API and update the states.""" - resource_type = self.entity_description.key - if resource_type == "devices/battery" and self.device is not None: - device_id = self.device.id - try: - registered_devs: list[FitbitDevice] = await self.api.async_get_devices() - except FitbitApiException: - self._attr_available = False - else: - self._attr_available = True - self.device = next( - device for device in registered_devs if device.id == device_id - ) - self._attr_native_value = self.device.battery - return - - try: - result = await self.api.async_get_latest_time_series(resource_type) - except FitbitApiException: - self._attr_available = False - else: - self._attr_available = True - self._attr_native_value = self.entity_description.value_fn(result) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.device = self.coordinator.data[self.device.id] + self._attr_native_value = self.device.battery + self.async_write_ha_state() diff --git a/tests/components/fitbit/test_init.py b/tests/components/fitbit/test_init.py index 32dc9b0cc98..b6bf75c1c69 100644 --- a/tests/components/fitbit/test_init.py +++ b/tests/components/fitbit/test_init.py @@ -4,6 +4,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus import pytest +from requests_mock.mocker import Mocker from homeassistant.components.fitbit.const import ( CONF_CLIENT_ID, @@ -16,6 +17,7 @@ from homeassistant.core import HomeAssistant from .conftest import ( CLIENT_ID, CLIENT_SECRET, + DEVICES_API_URL, FAKE_ACCESS_TOKEN, FAKE_REFRESH_TOKEN, SERVER_ACCESS_TOKEN, @@ -125,3 +127,50 @@ async def test_token_requires_reauth( flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 assert flows[0]["step_id"] == "reauth_confirm" + + +async def test_device_update_coordinator_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_device_update_coordinator_reauth( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, + requests_mock: Mocker, +) -> None: + """Test case where the device update coordinator fails on the first request.""" + assert config_entry.state == ConfigEntryState.NOT_LOADED + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert not await integration_setup() + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 7c980ac84a7..926b599dfb5 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -27,6 +27,8 @@ from .conftest import ( timeseries_response, ) +from tests.common import MockConfigEntry + DEVICE_RESPONSE_CHARGE_2 = { "battery": "Medium", "batteryLevel": 60, @@ -577,6 +579,43 @@ async def test_sensor_update_failed( assert state assert state.state == "unavailable" + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_failed_requires_reauth( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test a sensor update request requires reauth.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + status_code=HTTPStatus.UNAUTHORIZED, + json={ + "errors": [{"errorType": "invalid_grant"}], + }, + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + @pytest.mark.parametrize( ("scopes", "mock_devices"), @@ -594,11 +633,6 @@ async def test_device_battery_level_update_failed( "GET", DEVICES_API_URL, [ - { - "status_code": HTTPStatus.OK, - "json": [DEVICE_RESPONSE_CHARGE_2], - }, - # A second spurious update request on startup { "status_code": HTTPStatus.OK, "json": [DEVICE_RESPONSE_CHARGE_2], @@ -626,7 +660,63 @@ async def test_device_battery_level_update_failed( # Request an update for the entity which will fail await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() state = hass.states.get("sensor.charge_2_battery") assert state assert state.state == "unavailable" + + # Verify the config entry is in a normal state (no reauth required) + flows = hass.config_entries.flow.async_progress() + assert not flows + + +@pytest.mark.parametrize( + ("scopes", "mock_devices"), + [(["settings"], None)], +) +async def test_device_battery_level_reauth_required( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + requests_mock: Mocker, +) -> None: + """Test API failure requires reauth.""" + + requests_mock.register_uri( + "GET", + DEVICES_API_URL, + [ + { + "status_code": HTTPStatus.OK, + "json": [DEVICE_RESPONSE_CHARGE_2], + }, + # Fail when requesting an update + { + "status_code": HTTPStatus.UNAUTHORIZED, + "json": { + "errors": [{"errorType": "invalid_grant"}], + }, + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "Medium" + + # Request an update for the entity which will fail + await async_update_entity(hass, "sensor.charge_2_battery") + await hass.async_block_till_done() + + state = hass.states.get("sensor.charge_2_battery") + assert state + assert state.state == "unavailable" + + # Verify that reauth is required + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" From c247170c90efadbaba554684766ffa61768323f1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 9 Oct 2023 01:32:47 -0500 Subject: [PATCH 305/968] Bump plexwebsocket to 0.0.14 (#101684) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 33641cdf44f..a11d2d865c2 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -10,7 +10,7 @@ "requirements": [ "PlexAPI==4.15.4", "plexauth==0.0.6", - "plexwebsocket==0.0.13" + "plexwebsocket==0.0.14" ], "zeroconf": ["_plexmediasvr._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e679f4b6f0..c2aaf4f9246 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1444,7 +1444,7 @@ pizzapi==0.0.3 plexauth==0.0.6 # homeassistant.components.plex -plexwebsocket==0.0.13 +plexwebsocket==0.0.14 # homeassistant.components.plugwise plugwise==0.33.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51a9025cbdc..7b1c68219cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1104,7 +1104,7 @@ pilight==0.1.1 plexauth==0.0.6 # homeassistant.components.plex -plexwebsocket==0.0.13 +plexwebsocket==0.0.14 # homeassistant.components.plugwise plugwise==0.33.0 From 548a73b36718fdd617c5204219a5bdb09f07f0cf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Oct 2023 08:49:59 +0200 Subject: [PATCH 306/968] Update ephem to 4.1.5 (#101676) --- homeassistant/components/season/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/season/manifest.json b/homeassistant/components/season/manifest.json index 3d3338beec7..0e758dc4296 100644 --- a/homeassistant/components/season/manifest.json +++ b/homeassistant/components/season/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_polling", "loggers": ["ephem"], "quality_scale": "internal", - "requirements": ["ephem==4.1.2"] + "requirements": ["ephem==4.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2aaf4f9246..a47eec01b86 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ enturclient==0.2.4 env-canada==0.5.37 # homeassistant.components.season -ephem==4.1.2 +ephem==4.1.5 # homeassistant.components.epson epson-projector==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b1c68219cf..d99e6c2d8f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -608,7 +608,7 @@ enocean==0.50 env-canada==0.5.37 # homeassistant.components.season -ephem==4.1.2 +ephem==4.1.5 # homeassistant.components.epson epson-projector==0.5.1 From b85a078235afc5a66889296c69ec4440d82827fe Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 9 Oct 2023 17:11:30 +1000 Subject: [PATCH 307/968] Add Cribl virtual integration (#101680) --- homeassistant/components/cribl/__init__.py | 1 + homeassistant/components/cribl/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/cribl/__init__.py create mode 100644 homeassistant/components/cribl/manifest.json diff --git a/homeassistant/components/cribl/__init__.py b/homeassistant/components/cribl/__init__.py new file mode 100644 index 00000000000..0f5be79f583 --- /dev/null +++ b/homeassistant/components/cribl/__init__.py @@ -0,0 +1 @@ +"""Cribl virtual integration for Home Assistant.""" diff --git a/homeassistant/components/cribl/manifest.json b/homeassistant/components/cribl/manifest.json new file mode 100644 index 00000000000..f870bd0b8c6 --- /dev/null +++ b/homeassistant/components/cribl/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "cribl", + "name": "Cribl", + "integration_type": "virtual", + "supported_by": "splunk" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 0a4aa220ace..b84db6bd1ac 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -946,6 +946,11 @@ "config_flow": true, "iot_class": "local_push" }, + "cribl": { + "name": "Cribl", + "integration_type": "virtual", + "supported_by": "splunk" + }, "crownstone": { "name": "Crownstone", "integration_type": "hub", From 18908740cadec7f80926e24c691c39bc9759dd2e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 9 Oct 2023 10:04:39 +0200 Subject: [PATCH 308/968] Fix typo in nextcloud strings (#101686) --- homeassistant/components/nextcloud/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index cfe57f201ca..f9f7e4c2294 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -91,7 +91,7 @@ "name": "Cache ttl" }, "nextcloud_database_size": { - "name": "Databse size" + "name": "Database size" }, "nextcloud_database_type": { "name": "Database type" From 1f122eb688e643ee0145e6942c9d0da9c838de90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Oct 2023 10:56:39 +0200 Subject: [PATCH 309/968] Adjust services supported by litterrobot vacuum (#95788) --- .../components/litterrobot/strings.json | 24 +++++++ .../components/litterrobot/vacuum.py | 41 ++++++++++- tests/components/litterrobot/test_vacuum.py | 72 +++++++++++++++++-- 3 files changed, 130 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 7acfad69735..85d75e13dd2 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -25,6 +25,30 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "issues": { + "service_deprecation_turn_off": { + "title": "Litter-Robot vaccum support for {old_service} is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "description": "Litter-Robot vaccum support for the {old_service} service is deprecated and will be removed in Home Assistant 2024.2; Please adjust any automation or script that uses the service to instead call {new_service} and select submit below to mark this issue as resolved." + } + } + } + }, + "service_deprecation_turn_on": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::litterrobot::issues::service_deprecation_turn_off::title%]", + "description": "[%key:component::litterrobot::issues::service_deprecation_turn_off::fix_flow::step::confirm::description%]" + } + } + } + } + }, "entity": { "binary_sensor": { "sleeping": { diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index d1352c1e45f..4b1a8effb98 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -20,7 +20,11 @@ from homeassistant.components.vacuum import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + issue_registry as ir, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -77,7 +81,7 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): _attr_supported_features = ( VacuumEntityFeature.START | VacuumEntityFeature.STATE - | VacuumEntityFeature.STATUS + | VacuumEntityFeature.STOP | VacuumEntityFeature.TURN_OFF | VacuumEntityFeature.TURN_ON ) @@ -97,15 +101,48 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the cleaner on, starting a clean cycle.""" await self.robot.set_power_status(True) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_on", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_on", + translation_placeholders={ + "old_service": "vacuum.turn_on", + "new_service": "vacuum.start", + }, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the unit off, stopping any cleaning in progress as is.""" await self.robot.set_power_status(False) + ir.async_create_issue( + self.hass, + DOMAIN, + "service_deprecation_turn_off", + breaks_in_ha_version="2024.2.0", + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="service_deprecation_turn_off", + translation_placeholders={ + "old_service": "vacuum.turn_off", + "new_service": "vacuum.stop", + }, + ) async def async_start(self) -> None: """Start a clean cycle.""" + await self.robot.set_power_status(True) await self.robot.start_cleaning() + async def async_stop(self, **kwargs: Any) -> None: + """Stop the vacuum cleaner.""" + await self.robot.set_power_status(False) + async def async_set_sleep_mode( self, enabled: bool, start_time: str | None = None ) -> None: diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 655f58fa94f..3aee7b5075f 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -12,6 +12,7 @@ from homeassistant.components.vacuum import ( ATTR_STATUS, DOMAIN as PLATFORM_DOMAIN, SERVICE_START, + SERVICE_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_DOCKED, @@ -19,7 +20,7 @@ from homeassistant.components.vacuum import ( ) from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -import homeassistant.helpers.entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from .common import VACUUM_ENTITY_ID from .conftest import setup_integration @@ -98,8 +99,17 @@ async def test_vacuum_with_error( ("service", "command", "extra"), [ (SERVICE_START, "start_cleaning", None), - (SERVICE_TURN_OFF, "set_power_status", None), - (SERVICE_TURN_ON, "set_power_status", None), + (SERVICE_STOP, "set_power_status", None), + ( + SERVICE_TURN_OFF, + "set_power_status", + {"issues": {(DOMAIN, "service_deprecation_turn_off")}}, + ), + ( + SERVICE_TURN_ON, + "set_power_status", + {"issues": {(DOMAIN, "service_deprecation_turn_on")}}, + ), ( SERVICE_SET_SLEEP_MODE, "set_sleep_mode", @@ -126,7 +136,7 @@ async def test_commands( extra = extra or {} data = {ATTR_ENTITY_ID: VACUUM_ENTITY_ID, **extra.get("data", {})} - deprecated = extra.get("deprecated", False) + issues = extra.get("issues", set()) await hass.services.async_call( COMPONENT_SERVICE_DOMAIN.get(service, PLATFORM_DOMAIN), @@ -135,4 +145,56 @@ async def test_commands( blocking=True, ) getattr(mock_account.robots[0], command).assert_called_once() - assert (f"'{DOMAIN}.{service}' service is deprecated" in caplog.text) is deprecated + + issue_registry = ir.async_get(hass) + assert set(issue_registry.issues.keys()) == issues + + +@pytest.mark.parametrize( + ("service", "issue_id", "placeholders"), + [ + ( + SERVICE_TURN_OFF, + "service_deprecation_turn_off", + { + "old_service": "vacuum.turn_off", + "new_service": "vacuum.stop", + }, + ), + ( + SERVICE_TURN_ON, + "service_deprecation_turn_on", + { + "old_service": "vacuum.turn_on", + "new_service": "vacuum.start", + }, + ), + ], +) +async def test_issues( + hass: HomeAssistant, + mock_account: MagicMock, + caplog: pytest.LogCaptureFixture, + service: str, + issue_id: str, + placeholders: dict[str, str], +) -> None: + """Test issues raised by calling deprecated services.""" + await setup_integration(hass, mock_account, PLATFORM_DOMAIN) + + vacuum = hass.states.get(VACUUM_ENTITY_ID) + assert vacuum + assert vacuum.state == STATE_DOCKED + + await hass.services.async_call( + PLATFORM_DOMAIN, + service, + {ATTR_ENTITY_ID: VACUUM_ENTITY_ID}, + blocking=True, + ) + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, issue_id) + assert issue.is_fixable is True + assert issue.is_persistent is True + assert issue.translation_placeholders == placeholders From 7b78cfc0902a504f0985db7d9c49cd79a4258e8b Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Mon, 9 Oct 2023 18:15:59 +0900 Subject: [PATCH 310/968] Bump switchbot-api to 1.2.1 (#101664) SwitchBot Cloud: Dependency version up --- .../components/switchbot_cloud/__init__.py | 27 ++++++++++++------- .../components/switchbot_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index cf711fcc431..c34348137e7 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -32,6 +32,18 @@ class SwitchbotCloudData: devices: SwitchbotDevices +def prepare_device( + hass: HomeAssistant, + api: SwitchBotAPI, + device: Device | Remote, + coordinators: list[SwitchBotCoordinator], +) -> tuple[Device | Remote, SwitchBotCoordinator]: + """Instantiate coordinator and adds to list for gathering.""" + coordinator = SwitchBotCoordinator(hass, api, device) + coordinators.append(coordinator) + return (device, coordinator) + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -48,16 +60,14 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: except CannotConnect as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) - devices_and_coordinators = [ - (device, SwitchBotCoordinator(hass, api, device)) for device in devices - ] + coordinators: list[SwitchBotCoordinator] = [] hass.data.setdefault(DOMAIN, {}) data = SwitchbotCloudData( api=api, devices=SwitchbotDevices( switches=[ - (device, coordinator) - for device, coordinator in devices_and_coordinators + prepare_device(hass, api, device, coordinators) + for device in devices if isinstance(device, Device) and device.device_type.startswith("Plug") or isinstance(device, Remote) @@ -65,11 +75,10 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: ), ) hass.data[DOMAIN][config.entry_id] = data - _LOGGER.debug("Switches: %s", data.devices.switches) + for device_type, devices in vars(data.devices).items(): + _LOGGER.debug("%s: %s", device_type, devices) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather( - *[coordinator.async_refresh() for _, coordinator in devices_and_coordinators] - ) + await gather(*[coordinator.async_refresh() for coordinator in coordinators]) return True diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json index 0451217ca5f..9a4e4fbe196 100644 --- a/homeassistant/components/switchbot_cloud/manifest.json +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", "iot_class": "cloud_polling", "loggers": ["switchbot-api"], - "requirements": ["switchbot-api==1.1.0"] + "requirements": ["switchbot-api==1.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a47eec01b86..e130adfe58d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2517,7 +2517,7 @@ surepy==0.8.0 swisshydrodata==0.1.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.1.0 +switchbot-api==1.2.1 # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d99e6c2d8f2..3ed5cec5beb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1874,7 +1874,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.switchbot_cloud -switchbot-api==1.1.0 +switchbot-api==1.2.1 # homeassistant.components.system_bridge systembridgeconnector==3.8.4 From e6e190e7e214c2a8b13a5af5734234f7ed2a8bdf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Oct 2023 12:02:52 +0200 Subject: [PATCH 311/968] Remove unused HideSensitiveDataFilter (#101689) --- homeassistant/util/logging.py | 15 --------------- tests/util/test_logging.py | 13 ------------- 2 files changed, 28 deletions(-) diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 1db30a6bdfa..1328e8ded60 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -16,21 +16,6 @@ from homeassistant.core import HomeAssistant, callback, is_callback _T = TypeVar("_T") -class HideSensitiveDataFilter(logging.Filter): - """Filter API password calls.""" - - def __init__(self, text: str) -> None: - """Initialize sensitive data filter.""" - super().__init__() - self.text = text - - def filter(self, record: logging.LogRecord) -> bool: - """Hide sensitive data in messages.""" - record.msg = record.msg.replace(self.text, "*******") - - return True - - class HomeAssistantQueueHandler(logging.handlers.QueueHandler): """Process the log in another thread.""" diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py index 39bf38f56b1..a08311cca4f 100644 --- a/tests/util/test_logging.py +++ b/tests/util/test_logging.py @@ -11,19 +11,6 @@ from homeassistant.core import HomeAssistant, callback, is_callback import homeassistant.util.logging as logging_util -def test_sensitive_data_filter() -> None: - """Test the logging sensitive data filter.""" - log_filter = logging_util.HideSensitiveDataFilter("mock_sensitive") - - clean_record = logging.makeLogRecord({"msg": "clean log data"}) - log_filter.filter(clean_record) - assert clean_record.msg == "clean log data" - - sensitive_record = logging.makeLogRecord({"msg": "mock_sensitive log"}) - log_filter.filter(sensitive_record) - assert sensitive_record.msg == "******* log" - - async def test_logging_with_queue_handler() -> None: """Test logging with HomeAssistantQueueHandler.""" From 27b6325c32dcc6ed6d9e8d2dc04d9cdff3a74de0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Oct 2023 12:37:35 +0200 Subject: [PATCH 312/968] Update pylint to 3.0.1 (#101692) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index cc10efcd099..d583ad5cc21 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -14,7 +14,7 @@ mock-open==1.4.0 mypy==1.5.1 pre-commit==3.4.0 pydantic==1.10.12 -pylint==3.0.0 +pylint==3.0.1 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 From f7292d5b00100d04f3a0f967595becb716b45b7c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 9 Oct 2023 13:37:52 +0200 Subject: [PATCH 313/968] Add check that sensors don't have EntityCategory.CONFIG set (#101471) --- homeassistant/components/nextcloud/sensor.py | 6 ++--- homeassistant/components/sensor/__init__.py | 7 ++++++ tests/components/esphome/test_sensor.py | 4 ++-- tests/components/mqtt/test_common.py | 7 +++--- tests/components/sensor/test_init.py | 23 ++++++++++++++++++++ 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/nextcloud/sensor.py b/homeassistant/components/nextcloud/sensor.py index 0133a9e7f76..16c8adb77ce 100644 --- a/homeassistant/components/nextcloud/sensor.py +++ b/homeassistant/components/nextcloud/sensor.py @@ -348,7 +348,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_max_execution_time", translation_key="nextcloud_server_php_max_execution_time", device_class=SensorDeviceClass.DURATION, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfTime.SECONDS, ), @@ -356,7 +356,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_memory_limit", translation_key="nextcloud_server_php_memory_limit", device_class=SensorDeviceClass.DATA_SIZE, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfInformation.BYTES, suggested_display_precision=1, @@ -366,7 +366,7 @@ SENSORS: Final[list[NextcloudSensorEntityDescription]] = [ key="server_php_upload_max_filesize", translation_key="nextcloud_server_php_upload_max_filesize", device_class=SensorDeviceClass.DATA_SIZE, - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:language-php", native_unit_of_measurement=UnitOfInformation.BYTES, suggested_display_precision=1, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 55d2be6dfc6..10bf976f012 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -47,9 +47,11 @@ from homeassistant.const import ( # noqa: F401 DEVICE_CLASS_TIMESTAMP, DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, DEVICE_CLASS_VOLTAGE, + EntityCategory, UnitOfTemperature, ) from homeassistant.core import HomeAssistant, State, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, @@ -251,6 +253,11 @@ class SensorEntity(Entity): async def async_internal_added_to_hass(self) -> None: """Call when the sensor entity is added to hass.""" await super().async_internal_added_to_hass() + if self.entity_category == EntityCategory.CONFIG: + raise HomeAssistantError( + f"Entity {self.entity_id} cannot be added as the entity category is set to config" + ) + if not self.registry_entry: return self._async_read_entity_options() diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index e46906ffd33..cf7e2af02d7 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -97,7 +97,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( key=1, name="my sensor", unique_id="my_sensor", - entity_category=ESPHomeEntityCategory.CONFIG, + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, icon="mdi:leaf", ) ] @@ -117,7 +117,7 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None assert entry.unique_id == "my_sensor" - assert entry.entity_category is EntityCategory.CONFIG + assert entry.entity_category is EntityCategory.DIAGNOSTIC async def test_generic_numeric_sensor_state_class_measurement( diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 64bece5369e..7af7aa34647 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_RELOAD, STATE_UNAVAILABLE, + EntityCategory, ) from homeassistant.core import HomeAssistant from homeassistant.generated.mqtt import MQTT @@ -1635,9 +1636,9 @@ async def help_test_entity_category( entry = ent_registry.async_get(entity_id) assert entry is not None and entry.entity_category is None - # Discover an entity with entity category set to "config" + # Discover an entity with entity category set to "diagnostic" unique_id = "veryunique2" - config["entity_category"] = "config" + config["entity_category"] = EntityCategory.DIAGNOSTIC config["unique_id"] = unique_id data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/{unique_id}/config", data) @@ -1645,7 +1646,7 @@ async def help_test_entity_category( entity_id = ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, unique_id) assert entity_id is not None and hass.states.get(entity_id) entry = ent_registry.async_get(entity_id) - assert entry is not None and entry.entity_category == "config" + assert entry is not None and entry.entity_category == EntityCategory.DIAGNOSTIC # Discover an entity with entity category set to "no_such_category" unique_id = "veryunique3" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 395f6d41a14..fc714a543bf 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -26,6 +26,7 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNKNOWN, + EntityCategory, UnitOfEnergy, UnitOfLength, UnitOfMass, @@ -2496,3 +2497,25 @@ def test_device_class_units_state_classes(hass: HomeAssistant) -> None: ) - NON_NUMERIC_DEVICE_CLASSES - {SensorDeviceClass.MONETARY} # DEVICE_CLASS_STATE_CLASSES should include all device classes assert set(DEVICE_CLASS_STATE_CLASSES) == set(SensorDeviceClass) + + +async def test_entity_category_config_raises_error( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test error is raised when entity category is set to config.""" + platform = getattr(hass.components, "test.sensor") + platform.init(empty=True) + platform.ENTITIES["0"] = platform.MockSensor( + name="Test", entity_category=EntityCategory.CONFIG + ) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + assert ( + "Entity sensor.test cannot be added as the entity category is set to config" + in caplog.text + ) + + assert not hass.states.get("sensor.test") From 8a83e810b8b8b2f0ce090c19e4b582de7c968e19 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Oct 2023 13:41:51 +0200 Subject: [PATCH 314/968] Reset the threading.local _hass object in tests (#101700) --- tests/conftest.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 015cae17205..c04b90d349e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -559,6 +559,9 @@ async def hass( # Restore timezone, it is set when creating the hass object dt_util.DEFAULT_TIME_ZONE = orig_tz + # Reset the _Hass threading.local object + ha._hass.__dict__.clear() + for ex in exceptions: if ( request.module.__name__, From 6393171fa426729aeacfa088015a17082a11f582 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 9 Oct 2023 14:14:07 +0200 Subject: [PATCH 315/968] Adjust Hue integration to use Entity descriptions and translatable entity names (#101413) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/hue/event.py | 5 +- homeassistant/components/hue/scene.py | 29 +++---- homeassistant/components/hue/strings.json | 11 +-- homeassistant/components/hue/switch.py | 79 +++++++++++-------- .../components/hue/v2/binary_sensor.py | 65 +++++++++------ homeassistant/components/hue/v2/device.py | 57 ++++++++----- homeassistant/components/hue/v2/entity.py | 34 +------- homeassistant/components/hue/v2/group.py | 54 ++++++------- homeassistant/components/hue/v2/light.py | 5 ++ homeassistant/components/hue/v2/sensor.py | 57 ++++++++----- tests/components/hue/conftest.py | 27 +++---- tests/components/hue/const.py | 24 ++++++ tests/components/hue/test_binary_sensor.py | 12 ++- tests/components/hue/test_event.py | 4 +- tests/components/hue/test_scene.py | 2 +- tests/components/hue/test_switch.py | 10 +-- 16 files changed, 266 insertions(+), 209 deletions(-) diff --git a/homeassistant/components/hue/event.py b/homeassistant/components/hue/event.py index 914067509b7..da59515e7be 100644 --- a/homeassistant/components/hue/event.py +++ b/homeassistant/components/hue/event.py @@ -71,6 +71,7 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): key="button", device_class=EventDeviceClass.BUTTON, translation_key="button", + has_entity_name=True, ) def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -89,7 +90,8 @@ class HueButtonEventEntity(HueBaseEntity, EventEntity): @property def name(self) -> str: """Return name for the entity.""" - return f"{super().name} {self.resource.metadata.control_id}" + # this can be translated too as soon as we support arguments into translations ? + return f"Button {self.resource.metadata.control_id}" @callback def _handle_event(self, event_type: EventType, resource: Button) -> None: @@ -112,6 +114,7 @@ class HueRotaryEventEntity(HueBaseEntity, EventEntity): RelativeRotaryDirection.CLOCK_WISE.value, RelativeRotaryDirection.COUNTER_CLOCK_WISE.value, ], + has_entity_name=True, ) @callback diff --git a/homeassistant/components/hue/scene.py b/homeassistant/components/hue/scene.py index bd290d0bbb8..17f7a81b2a5 100644 --- a/homeassistant/components/hue/scene.py +++ b/homeassistant/components/hue/scene.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.components.scene import ATTR_TRANSITION, Scene as SceneEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -86,6 +86,8 @@ async def async_setup_entry( class HueSceneEntityBase(HueBaseEntity, SceneEntity): """Base Representation of a Scene entity from Hue Scenes.""" + _attr_has_entity_name = True + def __init__( self, bridge: HueBridge, @@ -97,6 +99,11 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): self.resource = resource self.controller = controller self.group = self.controller.get_group(self.resource.id) + # we create a virtual service/device for Hue zones/rooms + # so we have a parent for grouped lights and scenes + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) async def async_added_to_hass(self) -> None: """Call when entity is added.""" @@ -112,24 +119,8 @@ class HueSceneEntityBase(HueBaseEntity, SceneEntity): @property def name(self) -> str: - """Return default entity name.""" - return f"{self.group.metadata.name} {self.resource.metadata.name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device (service) info.""" - # we create a virtual service/device for Hue scenes - # so we have a parent for grouped lights and scenes - group_type = self.group.type.value.title() - return DeviceInfo( - identifiers={(DOMAIN, self.group.id)}, - entry_type=DeviceEntryType.SERVICE, - name=self.group.metadata.name, - manufacturer=self.bridge.api.config.bridge_device.product_data.manufacturer_name, - model=self.group.type.value.title(), - suggested_area=self.group.metadata.name if group_type == "Room" else None, - via_device=(DOMAIN, self.bridge.api.config.bridge_device.id), - ) + """Return name of the scene.""" + return self.resource.metadata.name class HueSceneEntity(HueSceneEntityBase): diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 1af6d3b58b5..4022c61bc36 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -97,6 +97,7 @@ }, "sensor": { "zigbee_connectivity": { + "name": "Zigbee connectivity", "state": { "connected": "[%key:common::state::connected%]", "disconnected": "[%key:common::state::disconnected%]", @@ -106,11 +107,11 @@ } }, "switch": { - "automation": { - "state": { - "on": "[%key:common::state::enabled%]", - "off": "[%key:common::state::disabled%]" - } + "motion_sensor_enabled": { + "name": "Motion sensor enabled" + }, + "light_sensor_enabled": { + "name": "Light sensor enabled" } } }, diff --git a/homeassistant/components/hue/switch.py b/homeassistant/components/hue/switch.py index 0fb2ebd6b52..c9da30a779c 100644 --- a/homeassistant/components/hue/switch.py +++ b/homeassistant/components/hue/switch.py @@ -1,7 +1,7 @@ """Support for switch platform for Hue resources (V2 only).""" from __future__ import annotations -from typing import Any, TypeAlias +from typing import Any from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.config import BehaviorInstance, BehaviorInstanceController @@ -27,12 +27,6 @@ from .bridge import HueBridge from .const import DOMAIN from .v2.entity import HueBaseEntity -ControllerType: TypeAlias = ( - BehaviorInstanceController | LightLevelController | MotionController -) - -SensingService: TypeAlias = LightLevel | Motion - async def async_setup_entry( hass: HomeAssistant, @@ -48,20 +42,22 @@ async def async_setup_entry( raise NotImplementedError("Switch support is only available for V2 bridges") @callback - def register_items(controller: ControllerType): + def register_items( + controller: BehaviorInstanceController + | LightLevelController + | MotionController, + switch_class: type[ + HueBehaviorInstanceEnabledEntity + | HueLightSensorEnabledEntity + | HueMotionSensorEnabledEntity + ], + ): @callback def async_add_entity( - event_type: EventType, resource: SensingService | BehaviorInstance + event_type: EventType, resource: BehaviorInstance | LightLevel | Motion ) -> None: """Add entity from Hue resource.""" - if isinstance(resource, BehaviorInstance): - async_add_entities( - [HueBehaviorInstanceEnabledEntity(bridge, controller, resource)] - ) - else: - async_add_entities( - [HueSensingServiceEnabledEntity(bridge, controller, resource)] - ) + async_add_entities([switch_class(bridge, api.sensors.motion, resource)]) # add all current items in controller for item in controller: @@ -75,15 +71,23 @@ async def async_setup_entry( ) # setup for each switch-type hue resource - register_items(api.sensors.motion) - register_items(api.sensors.light_level) - register_items(api.config.behavior_instance) + register_items(api.sensors.motion, HueMotionSensorEnabledEntity) + register_items(api.sensors.light_level, HueLightSensorEnabledEntity) + register_items(api.config.behavior_instance, HueBehaviorInstanceEnabledEntity) class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): """Representation of a Switch entity from a Hue resource that can be toggled enabled.""" controller: BehaviorInstanceController | LightLevelController | MotionController + resource: BehaviorInstance | LightLevel | Motion + + entity_description = SwitchEntityDescription( + key="sensing_service_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + ) @property def is_on(self) -> bool: @@ -103,16 +107,6 @@ class HueResourceEnabledEntity(HueBaseEntity, SwitchEntity): ) -class HueSensingServiceEnabledEntity(HueResourceEnabledEntity): - """Representation of a Switch entity from Hue SensingService.""" - - entity_description = SwitchEntityDescription( - key="behavior_instance", - device_class=SwitchDeviceClass.SWITCH, - entity_category=EntityCategory.CONFIG, - ) - - class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): """Representation of a Switch entity to enable/disable a Hue Behavior Instance.""" @@ -123,10 +117,33 @@ class HueBehaviorInstanceEnabledEntity(HueResourceEnabledEntity): device_class=SwitchDeviceClass.SWITCH, entity_category=EntityCategory.CONFIG, has_entity_name=False, - translation_key="automation", ) @property def name(self) -> str: """Return name for this entity.""" return f"Automation: {self.resource.metadata.name}" + + +class HueMotionSensorEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue motion sensor.""" + + entity_description = SwitchEntityDescription( + key="motion_sensor_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + translation_key="motion_sensor_enabled", + ) + + +class HueLightSensorEnabledEntity(HueResourceEnabledEntity): + """Representation of a Switch entity to enable/disable a Hue light sensor.""" + + entity_description = SwitchEntityDescription( + key="light_sensor_enabled", + device_class=SwitchDeviceClass.SWITCH, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + translation_key="light_sensor_enabled", + ) diff --git a/homeassistant/components/hue/v2/binary_sensor.py b/homeassistant/components/hue/v2/binary_sensor.py index 1eded0429b8..f1bcd0bbbe3 100644 --- a/homeassistant/components/hue/v2/binary_sensor.py +++ b/homeassistant/components/hue/v2/binary_sensor.py @@ -24,8 +24,10 @@ from aiohue.v2.models.tamper import Tamper, TamperState from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,25 +82,17 @@ async def async_setup_entry( register_items(api.sensors.tamper, HueTamperSensor) -class HueBinarySensorBase(HueBaseEntity, BinarySensorEntity): - """Representation of a Hue binary_sensor.""" - - def __init__( - self, - bridge: HueBridge, - controller: ControllerType, - resource: SensorType, - ) -> None: - """Initialize the binary sensor.""" - super().__init__(bridge, controller, resource) - self.resource = resource - self.controller = controller - - -class HueMotionSensor(HueBinarySensorBase): +class HueMotionSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Motion sensor.""" - _attr_device_class = BinarySensorDeviceClass.MOTION + controller: CameraMotionController | MotionController + resource: CameraMotion | Motion + + entity_description = BinarySensorEntityDescription( + key="motion_sensor", + device_class=BinarySensorDeviceClass.MOTION, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: @@ -109,10 +103,17 @@ class HueMotionSensor(HueBinarySensorBase): return self.resource.motion.value -class HueEntertainmentActiveSensor(HueBinarySensorBase): +class HueEntertainmentActiveSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Entertainment Configuration as binary sensor.""" - _attr_device_class = BinarySensorDeviceClass.RUNNING + controller: EntertainmentConfigurationController + resource: EntertainmentConfiguration + + entity_description = BinarySensorEntityDescription( + key="entertainment_active_sensor", + device_class=BinarySensorDeviceClass.RUNNING, + has_entity_name=False, + ) @property def is_on(self) -> bool | None: @@ -122,14 +123,20 @@ class HueEntertainmentActiveSensor(HueBinarySensorBase): @property def name(self) -> str: """Return sensor name.""" - type_title = self.resource.type.value.replace("_", " ").title() - return f"{self.resource.metadata.name}: {type_title}" + return self.resource.metadata.name -class HueContactSensor(HueBinarySensorBase): +class HueContactSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Contact sensor.""" - _attr_device_class = BinarySensorDeviceClass.OPENING + controller: ContactController + resource: Contact + + entity_description = BinarySensorEntityDescription( + key="contact_sensor", + device_class=BinarySensorDeviceClass.OPENING, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: @@ -140,10 +147,18 @@ class HueContactSensor(HueBinarySensorBase): return self.resource.contact_report.state != ContactState.CONTACT -class HueTamperSensor(HueBinarySensorBase): +class HueTamperSensor(HueBaseEntity, BinarySensorEntity): """Representation of a Hue Tamper sensor.""" - _attr_device_class = BinarySensorDeviceClass.TAMPER + controller: TamperController + resource: Tamper + + entity_description = BinarySensorEntityDescription( + key="tamper_sensor", + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + ) @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/hue/v2/device.py b/homeassistant/components/hue/v2/device.py index 6fed4bc16d1..75f474cc0ea 100644 --- a/homeassistant/components/hue/v2/device.py +++ b/homeassistant/components/hue/v2/device.py @@ -3,7 +3,9 @@ from typing import TYPE_CHECKING from aiohue.v2 import HueBridgeV2 from aiohue.v2.controllers.events import EventType +from aiohue.v2.controllers.groups import Room, Zone from aiohue.v2.models.device import Device, DeviceArchetypes +from aiohue.v2.models.resource import ResourceTypes from homeassistant.const import ( ATTR_CONNECTIONS, @@ -33,23 +35,38 @@ async def async_setup_devices(bridge: "HueBridge"): dev_controller = api.devices @callback - def add_device(hue_device: Device) -> dr.DeviceEntry: + def add_device(hue_resource: Device | Room | Zone) -> dr.DeviceEntry: """Register a Hue device in device registry.""" - model = f"{hue_device.product_data.product_name} ({hue_device.product_data.model_id})" + if isinstance(hue_resource, (Room, Zone)): + # Register a Hue Room/Zone as service in HA device registry. + return dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN, hue_resource.id)}, + name=hue_resource.metadata.name, + model=hue_resource.type.value.title(), + manufacturer=api.config.bridge_device.product_data.manufacturer_name, + via_device=(DOMAIN, api.config.bridge_device.id), + suggested_area=hue_resource.metadata.name + if hue_resource.type == ResourceTypes.ROOM + else None, + ) + # Register a Hue device resource as device in HA device registry. + model = f"{hue_resource.product_data.product_name} ({hue_resource.product_data.model_id})" params = { - ATTR_IDENTIFIERS: {(DOMAIN, hue_device.id)}, - ATTR_SW_VERSION: hue_device.product_data.software_version, - ATTR_NAME: hue_device.metadata.name, + ATTR_IDENTIFIERS: {(DOMAIN, hue_resource.id)}, + ATTR_SW_VERSION: hue_resource.product_data.software_version, + ATTR_NAME: hue_resource.metadata.name, ATTR_MODEL: model, - ATTR_MANUFACTURER: hue_device.product_data.manufacturer_name, + ATTR_MANUFACTURER: hue_resource.product_data.manufacturer_name, } - if room := dev_controller.get_room(hue_device.id): + if room := dev_controller.get_room(hue_resource.id): params[ATTR_SUGGESTED_AREA] = room.metadata.name - if hue_device.metadata.archetype == DeviceArchetypes.BRIDGE_V2: + if hue_resource.metadata.archetype == DeviceArchetypes.BRIDGE_V2: params[ATTR_IDENTIFIERS].add((DOMAIN, api.config.bridge_id)) else: params[ATTR_VIA_DEVICE] = (DOMAIN, api.config.bridge_device.id) - zigbee = dev_controller.get_zigbee_connectivity(hue_device.id) + zigbee = dev_controller.get_zigbee_connectivity(hue_resource.id) if zigbee and zigbee.mac_address: params[ATTR_CONNECTIONS] = {(dr.CONNECTION_NETWORK_MAC, zigbee.mac_address)} @@ -63,25 +80,27 @@ async def async_setup_devices(bridge: "HueBridge"): dev_reg.async_remove_device(device.id) @callback - def handle_device_event(evt_type: EventType, hue_device: Device) -> None: - """Handle event from Hue devices controller.""" + def handle_device_event( + evt_type: EventType, hue_resource: Device | Room | Zone + ) -> None: + """Handle event from Hue controller.""" if evt_type == EventType.RESOURCE_DELETED: - remove_device(hue_device.id) + remove_device(hue_resource.id) else: # updates to existing device will also be handled by this call - add_device(hue_device) + add_device(hue_resource) - # create/update all current devices found in controller + # create/update all current devices found in controllers known_devices = [add_device(hue_device) for hue_device in dev_controller] + known_devices += [add_device(hue_room) for hue_room in api.groups.room] + known_devices += [add_device(hue_zone) for hue_zone in api.groups.zone] # Check for nodes that no longer exist and remove them for device in dr.async_entries_for_config_entry(dev_reg, entry.entry_id): if device not in known_devices: - # handle case where a virtual device was created for a Hue group - hue_dev_id = next(x[1] for x in device.identifiers if x[0] == DOMAIN) - if hue_dev_id in api.groups: - continue dev_reg.async_remove_device(device.id) - # add listener for updates on Hue devices controller + # add listener for updates on Hue controllers entry.async_on_unload(dev_controller.subscribe(handle_device_event)) + entry.async_on_unload(api.groups.room.subscribe(handle_device_event)) + entry.async_on_unload(api.groups.zone.subscribe(handle_device_event)) diff --git a/homeassistant/components/hue/v2/entity.py b/homeassistant/components/hue/v2/entity.py index f4c76618009..75e4bb1edd4 100644 --- a/homeassistant/components/hue/v2/entity.py +++ b/homeassistant/components/hue/v2/entity.py @@ -9,10 +9,7 @@ from aiohue.v2.models.resource import ResourceTypes from aiohue.v2.models.zigbee_connectivity import ConnectivityServiceStatus from homeassistant.core import callback -from homeassistant.helpers.device_registry import ( - DeviceInfo, - async_get as async_get_device_registry, -) +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_registry import async_get as async_get_entity_registry @@ -72,24 +69,6 @@ class HueBaseEntity(Entity): self._ignore_availability = None self._last_state = None - @property - def name(self) -> str: - """Return name for the entity.""" - if self.device is None: - # this is just a guard - # creating a pretty name for device-less entities (e.g. groups/scenes) - # should be handled in the platform instead - return self.resource.type.value - dev_name = self.device.metadata.name - # if resource is a light, use the device name itself - if self.resource.type == ResourceTypes.LIGHT: - return dev_name - # for sensors etc, use devicename + pretty name of type - type_title = RESOURCE_TYPE_NAMES.get( - self.resource.type, self.resource.type.value.replace("_", " ").title() - ) - return f"{dev_name} {type_title}" - async def async_added_to_hass(self) -> None: """Call when entity is added.""" self._check_availability() @@ -146,19 +125,12 @@ class HueBaseEntity(Entity): def _handle_event(self, event_type: EventType, resource: HueResource) -> None: """Handle status event for this resource (or it's parent).""" if event_type == EventType.RESOURCE_DELETED: - # handle removal of room and zone 'virtual' devices/services - # regular devices are removed automatically by the logic in device.py. - if resource.type in (ResourceTypes.ROOM, ResourceTypes.ZONE): - dev_reg = async_get_device_registry(self.hass) - if device := dev_reg.async_get_device( - identifiers={(DOMAIN, resource.id)} - ): - dev_reg.async_remove_device(device.id) # cleanup entities that are not strictly device-bound and have the bridge as parent - if self.device is None: + if self.device is None and resource.id == self.resource.id: ent_reg = async_get_entity_registry(self.hass) ent_reg.async_remove(self.entity_id) return + self.logger.debug("Received status update for %s", self.entity_id) self._check_availability() self.on_update() diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 9985d37627b..7d63df131d8 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -1,6 +1,7 @@ """Support for Hue groups (room/zone).""" from __future__ import annotations +import asyncio from typing import Any from aiohue.v2 import HueBridgeV2 @@ -17,11 +18,12 @@ from homeassistant.components.light import ( FLASH_SHORT, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from ..bridge import HueBridge @@ -43,18 +45,26 @@ async def async_setup_entry( bridge: HueBridge = hass.data[DOMAIN][config_entry.entry_id] api: HueBridgeV2 = bridge.api - @callback - def async_add_light(event_type: EventType, resource: GroupedLight) -> None: + async def async_add_light(event_type: EventType, resource: GroupedLight) -> None: """Add Grouped Light for Hue Room/Zone.""" - group = api.groups.grouped_light.get_zone(resource.id) + # delay group creation a bit due to a race condition where the + # grouped_light resource is created before the zone/room + retries = 5 + while ( + retries + and (group := api.groups.grouped_light.get_zone(resource.id)) is None + ): + retries -= 1 + await asyncio.sleep(0.5) if group is None: + # guard, just in case return light = GroupedHueLight(bridge, resource, group) async_add_entities([light]) # add current items for item in api.groups.grouped_light.items: - async_add_light(EventType.RESOURCE_ADDED, item) + await async_add_light(EventType.RESOURCE_ADDED, item) # register listener for new grouped_light config_entry.async_on_unload( @@ -67,7 +77,12 @@ async def async_setup_entry( class GroupedHueLight(HueBaseEntity, LightEntity): """Representation of a Grouped Hue light.""" - _attr_icon = "mdi:lightbulb-group" + entity_description = LightEntityDescription( + key="hue_grouped_light", + icon="mdi:lightbulb-group", + has_entity_name=True, + name=None, + ) def __init__( self, bridge: HueBridge, resource: GroupedLight, group: Room | Zone @@ -81,7 +96,11 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.api: HueBridgeV2 = bridge.api self._attr_supported_features |= LightEntityFeature.FLASH self._attr_supported_features |= LightEntityFeature.TRANSITION - + # we create a virtual service/device for Hue zones/rooms + # so we have a parent for grouped lights and scenes + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.group.id)}, + ) self._dynamic_mode_active = False self._update_values() @@ -103,11 +122,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.api.lights.subscribe(self._handle_event, light_ids) ) - @property - def name(self) -> str: - """Return name of room/zone for this grouped light.""" - return self.group.metadata.name - @property def is_on(self) -> bool: """Return true if light is on.""" @@ -131,22 +145,6 @@ class GroupedHueLight(HueBaseEntity, LightEntity): "dynamics": self._dynamic_mode_active, } - @property - def device_info(self) -> DeviceInfo: - """Return device (service) info.""" - # we create a virtual service/device for Hue zones/rooms - # so we have a parent for grouped lights and scenes - model = self.group.type.value.title() - return DeviceInfo( - identifiers={(DOMAIN, self.group.id)}, - entry_type=DeviceEntryType.SERVICE, - name=self.group.metadata.name, - manufacturer=self.api.config.bridge_device.product_data.manufacturer_name, - model=model, - suggested_area=self.group.metadata.name if model == "Room" else None, - via_device=(DOMAIN, self.api.config.bridge_device.id), - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the grouped_light on.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index f42da406599..ed5d0151b03 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -19,6 +19,7 @@ from homeassistant.components.light import ( FLASH_SHORT, ColorMode, LightEntity, + LightEntityDescription, LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry @@ -69,6 +70,10 @@ async def async_setup_entry( class HueLight(HueBaseEntity, LightEntity): """Representation of a Hue light.""" + entity_description = LightEntityDescription( + key="hue_light", has_entity_name=True, name=None + ) + def __init__( self, bridge: HueBridge, controller: LightsController, resource: Light ) -> None: diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index 4bfb727b917..56f708e2dfd 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -20,6 +20,7 @@ from aiohue.v2.models.zigbee_connectivity import ZigbeeConnectivity from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -93,9 +94,13 @@ class HueSensorBase(HueBaseEntity, SensorEntity): class HueTemperatureSensor(HueSensorBase): """Representation of a Hue Temperature sensor.""" - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="temperature_sensor", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + ) @property def native_value(self) -> float: @@ -106,9 +111,13 @@ class HueTemperatureSensor(HueSensorBase): class HueLightLevelSensor(HueSensorBase): """Representation of a Hue LightLevel (illuminance) sensor.""" - _attr_native_unit_of_measurement = LIGHT_LUX - _attr_device_class = SensorDeviceClass.ILLUMINANCE - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="lightlevel_sensor", + device_class=SensorDeviceClass.ILLUMINANCE, + native_unit_of_measurement=LIGHT_LUX, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + ) @property def native_value(self) -> int: @@ -130,10 +139,14 @@ class HueLightLevelSensor(HueSensorBase): class HueBatterySensor(HueSensorBase): """Representation of a Hue Battery sensor.""" - _attr_native_unit_of_measurement = PERCENTAGE - _attr_device_class = SensorDeviceClass.BATTERY - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_state_class = SensorStateClass.MEASUREMENT + entity_description = SensorEntityDescription( + key="battery_sensor", + device_class=SensorDeviceClass.BATTERY, + native_unit_of_measurement=PERCENTAGE, + has_entity_name=True, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ) @property def native_value(self) -> int: @@ -151,16 +164,20 @@ class HueBatterySensor(HueSensorBase): class HueZigbeeConnectivitySensor(HueSensorBase): """Representation of a Hue ZigbeeConnectivity sensor.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_translation_key = "zigbee_connectivity" - _attr_device_class = SensorDeviceClass.ENUM - _attr_options = [ - "connected", - "disconnected", - "connectivity_issue", - "unidirectional_incoming", - ] - _attr_entity_registry_enabled_default = False + entity_description = SensorEntityDescription( + key="zigbee_connectivity_sensor", + device_class=SensorDeviceClass.ENUM, + has_entity_name=True, + entity_category=EntityCategory.DIAGNOSTIC, + translation_key="zigbee_connectivity", + options=[ + "connected", + "disconnected", + "connectivity_issue", + "unidirectional_incoming", + ], + entity_registry_enabled_default=False, + ) @property def native_value(self) -> str: diff --git a/tests/components/hue/conftest.py b/tests/components/hue/conftest.py index d730d3f18f5..3350ea15185 100644 --- a/tests/components/hue/conftest.py +++ b/tests/components/hue/conftest.py @@ -3,6 +3,7 @@ import asyncio from collections import deque import json import logging +from typing import Any from unittest.mock import AsyncMock, Mock, patch import aiohue.v1 as aiohue_v1 @@ -12,6 +13,7 @@ import pytest from homeassistant.components import hue from homeassistant.components.hue.v1 import sensor_base as hue_sensor_base +from homeassistant.components.hue.v2.device import async_setup_devices from homeassistant.setup import async_setup_component from tests.common import ( @@ -20,6 +22,7 @@ from tests.common import ( load_fixture, mock_device_registry, ) +from tests.components.hue.const import FAKE_BRIDGE, FAKE_BRIDGE_DEVICE @pytest.fixture(autouse=True) @@ -56,6 +59,8 @@ def create_mock_bridge(hass, api_version=1): async def async_initialize_bridge(): if bridge.config_entry: hass.data.setdefault(hue.DOMAIN, {})[bridge.config_entry.entry_id] = bridge + if bridge.api_version == 2: + await async_setup_devices(bridge) return True bridge.async_initialize_bridge = async_initialize_bridge @@ -140,22 +145,10 @@ def create_mock_api_v2(hass): """Create a mock V2 API.""" api = Mock(spec=aiohue_v2.HueBridgeV2) api.initialize = AsyncMock() - api.config = Mock( - bridge_id="aabbccddeeffggh", - mac_address="00:17:88:01:aa:bb:fd:c7", - model_id="BSB002", - api_version="9.9.9", - software_version="1935144040", - bridge_device=Mock( - id="4a507550-8742-4087-8bf5-c2334f29891c", - product_data=Mock(manufacturer_name="Mock"), - ), - spec=aiohue_v2.ConfigController, - ) - api.config.name = "Home" api.mock_requests = [] api.logger = logging.getLogger(__name__) + api.config = aiohue_v2.ConfigController(api) api.events = aiohue_v2.EventStream(api) api.devices = aiohue_v2.DevicesController(api) api.lights = aiohue_v2.LightsController(api) @@ -171,9 +164,13 @@ def create_mock_api_v2(hass): api.request = mock_request - async def load_test_data(data): + async def load_test_data(data: list[dict[str, Any]]): """Load test data into controllers.""" - api.config = aiohue_v2.ConfigController(api) + + # append default bridge if none explicitly given in test data + if not any(x for x in data if x["type"] == "bridge"): + data.append(FAKE_BRIDGE) + data.append(FAKE_BRIDGE_DEVICE) await asyncio.gather( api.config.initialize(data), diff --git a/tests/components/hue/const.py b/tests/components/hue/const.py index 415fe1324b7..252c9da9a9d 100644 --- a/tests/components/hue/const.py +++ b/tests/components/hue/const.py @@ -1,5 +1,29 @@ """Constants for Hue tests.""" +FAKE_BRIDGE = { + "bridge_id": "aabbccddeeffggh", + "id": "07dd5849-abcd-efgh-b9b9-eb540408ce00", + "id_v1": "", + "owner": {"rid": "4a507550-8742-4087-8bf5-c2334f29891c", "rtype": "device"}, + "time_zone": {"time_zone": "Europe/Amsterdam"}, + "type": "bridge", +} + +FAKE_BRIDGE_DEVICE = { + "id": "4a507550-8742-4087-8bf5-c2334f29891c", + "id_v1": "", + "metadata": {"archetype": "bridge_v2", "name": "Philips hue"}, + "product_data": { + "certified": True, + "manufacturer_name": "Signify Netherlands B.V.", + "model_id": "BSB002", + "product_archetype": "bridge_v2", + "product_name": "Philips hue", + "software_version": "1.50.1950111030", + }, + "services": [{"rid": "07dd5849-abcd-efgh-b9b9-eb540408ce00", "rtype": "bridge"}], + "type": "device", +} FAKE_DEVICE = { "id": "fake_device_id_1", diff --git a/tests/components/hue/test_binary_sensor.py b/tests/components/hue/test_binary_sensor.py index 3846f17aa76..ab6f4ab0581 100644 --- a/tests/components/hue/test_binary_sensor.py +++ b/tests/components/hue/test_binary_sensor.py @@ -25,19 +25,17 @@ async def test_binary_sensors( assert sensor.attributes["device_class"] == "motion" # test entertainment room active sensor - sensor = hass.states.get( - "binary_sensor.entertainmentroom_1_entertainment_configuration" - ) + sensor = hass.states.get("binary_sensor.entertainmentroom_1") assert sensor is not None assert sensor.state == "off" - assert sensor.name == "Entertainmentroom 1: Entertainment Configuration" + assert sensor.name == "Entertainmentroom 1" assert sensor.attributes["device_class"] == "running" # test contact sensor - sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + sensor = hass.states.get("binary_sensor.test_contact_sensor_opening") assert sensor is not None assert sensor.state == "off" - assert sensor.name == "Test contact sensor Contact" + assert sensor.name == "Test contact sensor Opening" assert sensor.attributes["device_class"] == "opening" # test contact sensor disabled == state unknown mock_bridge_v2.api.emit_event( @@ -49,7 +47,7 @@ async def test_binary_sensors( }, ) await hass.async_block_till_done() - sensor = hass.states.get("binary_sensor.test_contact_sensor_contact") + sensor = hass.states.get("binary_sensor.test_contact_sensor_opening") assert sensor.state == "unknown" # test tamper sensor diff --git a/tests/components/hue/test_event.py b/tests/components/hue/test_event.py index a3779c6b0e3..9953bb11796 100644 --- a/tests/components/hue/test_event.py +++ b/tests/components/hue/test_event.py @@ -57,7 +57,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: await mock_bridge_v2.api.load_test_data([FAKE_DEVICE, FAKE_ZIGBEE_CONNECTIVITY]) await setup_platform(hass, mock_bridge_v2, "event") - test_entity_id = "event.hue_mocked_device_relative_rotary" + test_entity_id = "event.hue_mocked_device_rotary" # verify entity does not exist before we start assert hass.states.get(test_entity_id) is None @@ -70,7 +70,7 @@ async def test_sensor_add_update(hass: HomeAssistant, mock_bridge_v2) -> None: state = hass.states.get(test_entity_id) assert state is not None assert state.state == "unknown" - assert state.name == "Hue mocked device Relative Rotary" + assert state.name == "Hue mocked device Rotary" # check event_types assert state.attributes[ATTR_EVENT_TYPES] == ["clock_wise", "counter_clock_wise"] diff --git a/tests/components/hue/test_scene.py b/tests/components/hue/test_scene.py index 7785b9d4628..5fa35cec5b4 100644 --- a/tests/components/hue/test_scene.py +++ b/tests/components/hue/test_scene.py @@ -186,7 +186,7 @@ async def test_scene_updates( ) await hass.async_block_till_done() test_entity = hass.states.get(test_entity_id) - assert test_entity.name == "Test Room 2 Mocked Scene" + assert test_entity.attributes["group_name"] == "Test Room 2" # # test delete mock_bridge_v2.api.emit_event("delete", updated_resource) diff --git a/tests/components/hue/test_switch.py b/tests/components/hue/test_switch.py index e8cad2bc802..c3384ae1e44 100644 --- a/tests/components/hue/test_switch.py +++ b/tests/components/hue/test_switch.py @@ -18,9 +18,9 @@ async def test_switch( assert len(hass.states.async_all()) == 4 # test config switch to enable/disable motion sensor - test_entity = hass.states.get("switch.hue_motion_sensor_motion") + test_entity = hass.states.get("switch.hue_motion_sensor_motion_sensor_enabled") assert test_entity is not None - assert test_entity.name == "Hue motion sensor Motion" + assert test_entity.name == "Hue motion sensor Motion sensor enabled" assert test_entity.state == "on" assert test_entity.attributes["device_class"] == "switch" @@ -40,7 +40,7 @@ async def test_switch_turn_on_service( await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_motion_sensor_motion" + test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled" # call the HA turn_on service await hass.services.async_call( @@ -64,7 +64,7 @@ async def test_switch_turn_off_service( await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_motion_sensor_motion" + test_entity_id = "switch.hue_motion_sensor_motion_sensor_enabled" # verify the switch is on before we start assert hass.states.get(test_entity_id).state == "on" @@ -103,7 +103,7 @@ async def test_switch_added(hass: HomeAssistant, mock_bridge_v2) -> None: await setup_platform(hass, mock_bridge_v2, "switch") - test_entity_id = "switch.hue_mocked_device_motion" + test_entity_id = "switch.hue_mocked_device_motion_sensor_enabled" # verify entity does not exist before we start assert hass.states.get(test_entity_id) is None From b9090452dec729edc1a1c5bfa2092dd961ff9c1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 9 Oct 2023 14:41:30 +0200 Subject: [PATCH 316/968] Migrate Vulcan to has entity name (#99020) --- homeassistant/components/vulcan/calendar.py | 20 +++++++++++--------- homeassistant/components/vulcan/strings.json | 7 +++++++ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vulcan/calendar.py b/homeassistant/components/vulcan/calendar.py index 20c8ff78432..073ac88fbda 100644 --- a/homeassistant/components/vulcan/calendar.py +++ b/homeassistant/components/vulcan/calendar.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import date, datetime, timedelta import logging +from typing import cast from zoneinfo import ZoneInfo from aiohttp import ClientConnectorError @@ -56,26 +57,27 @@ async def async_setup_entry( class VulcanCalendarEntity(CalendarEntity): """A calendar entity.""" + _attr_has_entity_name = True + _attr_translation_key = "calendar" + def __init__(self, client, data, entity_id) -> None: """Create the Calendar entity.""" - self.student_info = data["student_info"] self._event: CalendarEvent | None = None self.client = client self.entity_id = entity_id - self._unique_id = f"vulcan_calendar_{self.student_info['id']}" - self._attr_name = f"Vulcan calendar - {self.student_info['full_name']}" - self._attr_unique_id = f"vulcan_calendar_{self.student_info['id']}" + student_info = data["student_info"] + self._attr_unique_id = f"vulcan_calendar_{student_info['id']}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, f"calendar_{self.student_info['id']}")}, + identifiers={(DOMAIN, f"calendar_{student_info['id']}")}, entry_type=DeviceEntryType.SERVICE, - name=f"{self.student_info['full_name']}: Calendar", + name=cast(str, student_info["full_name"]), model=( - f"{self.student_info['full_name']} -" - f" {self.student_info['class']} {self.student_info['school']}" + f"{student_info['full_name']} -" + f" {student_info['class']} {student_info['school']}" ), manufacturer="Uonet +", configuration_url=( - f"https://uonetplus.vulcan.net.pl/{self.student_info['symbol']}" + f"https://uonetplus.vulcan.net.pl/{student_info['symbol']}" ), ) diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 07a0510f482..4ec58b3a06c 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -51,5 +51,12 @@ } } } + }, + "entity": { + "calendar": { + "calendar": { + "name": "[%key:component::calendar::title%]" + } + } } } From 2e4df6d2f2571d14cad5068111c04b19e5fcf43d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 9 Oct 2023 09:01:05 -0400 Subject: [PATCH 317/968] Open a ZHA repair when network settings change (#99482) --- homeassistant/components/zha/__init__.py | 25 ++- homeassistant/components/zha/api.py | 22 +- homeassistant/components/zha/core/const.py | 4 - homeassistant/components/zha/core/gateway.py | 34 ++-- homeassistant/components/zha/radio_manager.py | 14 +- .../components/zha/repairs/__init__.py | 33 +++ .../repairs/network_settings_inconsistent.py | 151 ++++++++++++++ .../wrong_silabs_firmware.py} | 7 +- homeassistant/components/zha/strings.json | 15 ++ tests/components/zha/conftest.py | 144 +++++++++++-- tests/components/zha/test_api.py | 53 ++--- tests/components/zha/test_backup.py | 19 +- tests/components/zha/test_device_trigger.py | 5 +- tests/components/zha/test_gateway.py | 11 +- tests/components/zha/test_init.py | 10 +- tests/components/zha/test_radio_manager.py | 4 +- tests/components/zha/test_repairs.py | 190 ++++++++++++++++-- .../zha/test_silabs_multiprotocol.py | 28 +-- 18 files changed, 617 insertions(+), 152 deletions(-) create mode 100644 homeassistant/components/zha/repairs/__init__.py create mode 100644 homeassistant/components/zha/repairs/network_settings_inconsistent.py rename homeassistant/components/zha/{repairs.py => repairs/wrong_silabs_firmware.py} (93%) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 08db98cff6f..711ab2045eb 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -8,12 +8,13 @@ import re import voluptuous as vol from zhaquirks import setup as setup_quirks -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import NetworkSettingsInconsistent from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -26,7 +27,6 @@ from .core.const import ( BAUD_RATES, CONF_BAUDRATE, CONF_CUSTOM_QUIRKS_PATH, - CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_ENABLE_QUIRKS, CONF_RADIO_TYPE, @@ -42,6 +42,11 @@ from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE from .core.helpers import ZHAData, get_zha_data from .radio_manager import ZhaRadioManager +from .repairs.network_settings_inconsistent import warn_on_inconsistent_network_settings +from .repairs.wrong_silabs_firmware import ( + AlreadyRunningEZSP, + warn_on_wrong_silabs_firmware, +) DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -170,13 +175,23 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: await zha_gateway.async_initialize() + except NetworkSettingsInconsistent as exc: + await warn_on_inconsistent_network_settings( + hass, + config_entry=config_entry, + old_state=exc.old_state, + new_state=exc.new_state, + ) + raise HomeAssistantError( + "Network settings do not match most recent backup" + ) from exc except Exception: if RadioType[config_entry.data[CONF_RADIO_TYPE]] == RadioType.ezsp: try: - await repairs.warn_on_wrong_silabs_firmware( + await warn_on_wrong_silabs_firmware( hass, config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] ) - except repairs.AlreadyRunningEZSP as exc: + except AlreadyRunningEZSP as exc: # If connecting fails but we somehow probe EZSP (e.g. stuck in the # bootloader), reconnect, it should work raise ConfigEntryNotReady from exc diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f63fb9d09de..db0658eb632 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -10,8 +10,8 @@ from zigpy.types import Channels from zigpy.util import pick_optimal_channel from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType -from .core.gateway import ZHAGateway -from .core.helpers import get_zha_data, get_zha_gateway +from .core.helpers import get_zha_gateway +from .radio_manager import ZhaRadioManager if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry @@ -55,19 +55,13 @@ async def async_get_last_network_settings( if config_entry is None: config_entry = _get_config_entry(hass) - config = get_zha_data(hass).yaml_config - zha_gateway = ZHAGateway(hass, config, config_entry) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) - app_controller_cls, app_config = zha_gateway.get_application_controller_data() - app = app_controller_cls(app_config) - - try: - await app._load_db() # pylint: disable=protected-access - settings = max(app.backups, key=lambda b: b.backup_time) - except ValueError: - settings = None - finally: - await app.shutdown() + async with radio_mgr.connect_zigpy_app() as app: + try: + settings = max(app.backups, key=lambda b: b.backup_time) + except ValueError: + settings = None return settings diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index b37fa7ffe6d..c286d0112e9 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -7,7 +7,6 @@ import logging import bellows.zigbee.application import voluptuous as vol import zigpy.application -from zigpy.config import CONF_DEVICE_PATH # noqa: F401 import zigpy.types as t import zigpy_deconz.zigbee.application import zigpy_xbee.zigbee.application @@ -128,7 +127,6 @@ CONF_ALARM_ARM_REQUIRES_CODE = "alarm_arm_requires_code" CONF_BAUDRATE = "baudrate" CONF_CUSTOM_QUIRKS_PATH = "custom_quirks_path" -CONF_DATABASE = "database_path" CONF_DEFAULT_LIGHT_TRANSITION = "default_light_transition" CONF_DEVICE_CONFIG = "device_config" CONF_ENABLE_ENHANCED_LIGHT_TRANSITION = "enhanced_light_transition" @@ -138,8 +136,6 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state" CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join" CONF_ENABLE_QUIRKS = "enable_quirks" CONF_FLOWCONTROL = "flow_control" -CONF_NWK = "network" -CONF_NWK_CHANNEL = "channel" CONF_RADIO_TYPE = "radio_type" CONF_USB_PATH = "usb_path" CONF_USE_THREAD = "use_thread" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index c5d04dda961..796a3c2dc05 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import collections from collections.abc import Callable +from contextlib import suppress from datetime import timedelta from enum import Enum import itertools @@ -13,10 +14,17 @@ import time from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication -from zigpy.config import CONF_DEVICE +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK, + CONF_NWK_CHANNEL, + CONF_NWK_VALIDATE_SETTINGS, +) import zigpy.device import zigpy.endpoint -import zigpy.exceptions +from zigpy.exceptions import NetworkSettingsInconsistent, TransientConnectionError import zigpy.group from zigpy.types.named import EUI64 @@ -38,10 +46,6 @@ from .const import ( ATTR_NWK, ATTR_SIGNATURE, ATTR_TYPE, - CONF_DATABASE, - CONF_DEVICE_PATH, - CONF_NWK, - CONF_NWK_CHANNEL, CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, @@ -159,6 +163,9 @@ class ZHAGateway: app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] + if CONF_NWK_VALIDATE_SETTINGS not in app_config: + app_config[CONF_NWK_VALIDATE_SETTINGS] = True + # The bellows UART thread sometimes propagates a cancellation into the main Core # event loop, when a connection to a TCP coordinator fails in a specific way if ( @@ -199,7 +206,9 @@ class ZHAGateway: for attempt in range(STARTUP_RETRIES): try: await self.application_controller.startup(auto_form=True) - except zigpy.exceptions.TransientConnectionError as exc: + except NetworkSettingsInconsistent: + raise + except TransientConnectionError as exc: raise ConfigEntryNotReady from exc except Exception as exc: # pylint: disable=broad-except _LOGGER.warning( @@ -231,12 +240,13 @@ class ZHAGateway: self.application_controller.groups.add_listener(self) def _find_coordinator_device(self) -> zigpy.device.Device: + zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + if last_backup := self.application_controller.backups.most_recent_backup(): - zigpy_coordinator = self.application_controller.get_device( - ieee=last_backup.node_info.ieee - ) - else: - zigpy_coordinator = self.application_controller.get_device(nwk=0x0000) + with suppress(KeyError): + zigpy_coordinator = self.application_controller.get_device( + ieee=last_backup.node_info.ieee + ) return zigpy_coordinator diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index ca030600751..d20cf752a91 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -14,7 +14,12 @@ from bellows.config import CONF_USE_THREAD import voluptuous as vol from zigpy.application import ControllerApplication import zigpy.backups -from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH, CONF_NWK_BACKUP_ENABLED +from zigpy.config import ( + CONF_DATABASE, + CONF_DEVICE, + CONF_DEVICE_PATH, + CONF_NWK_BACKUP_ENABLED, +) from zigpy.exceptions import NetworkNotFormed from homeassistant import config_entries @@ -23,7 +28,6 @@ from homeassistant.core import HomeAssistant from . import repairs from .core.const import ( - CONF_DATABASE, CONF_RADIO_TYPE, CONF_ZIGPY, DEFAULT_DATABASE_NAME, @@ -218,8 +222,10 @@ class ZhaRadioManager: repairs.async_delete_blocking_issues(self.hass) return ProbeResult.RADIO_TYPE_DETECTED - with suppress(repairs.AlreadyRunningEZSP): - if await repairs.warn_on_wrong_silabs_firmware(self.hass, self.device_path): + with suppress(repairs.wrong_silabs_firmware.AlreadyRunningEZSP): + if await repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware( + self.hass, self.device_path + ): return ProbeResult.WRONG_FIRMWARE_INSTALLED return ProbeResult.PROBING_FAILED diff --git a/homeassistant/components/zha/repairs/__init__.py b/homeassistant/components/zha/repairs/__init__.py new file mode 100644 index 00000000000..a3c2ea6f292 --- /dev/null +++ b/homeassistant/components/zha/repairs/__init__.py @@ -0,0 +1,33 @@ +"""ZHA repairs for common environmental and device problems.""" +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from ..core.const import DOMAIN +from .network_settings_inconsistent import ( + ISSUE_INCONSISTENT_NETWORK_SETTINGS, + NetworkSettingsInconsistentFlow, +) +from .wrong_silabs_firmware import ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED + + +def async_delete_blocking_issues(hass: HomeAssistant) -> None: + """Delete repair issues that should disappear on a successful startup.""" + ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) + ir.async_delete_issue(hass, DOMAIN, ISSUE_INCONSISTENT_NETWORK_SETTINGS) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None] | None, +) -> RepairsFlow: + """Create flow.""" + if issue_id == ISSUE_INCONSISTENT_NETWORK_SETTINGS: + return NetworkSettingsInconsistentFlow(hass, cast(dict[str, Any], data)) + + return ConfirmRepairFlow() diff --git a/homeassistant/components/zha/repairs/network_settings_inconsistent.py b/homeassistant/components/zha/repairs/network_settings_inconsistent.py new file mode 100644 index 00000000000..0a478f4b36a --- /dev/null +++ b/homeassistant/components/zha/repairs/network_settings_inconsistent.py @@ -0,0 +1,151 @@ +"""ZHA repair for inconsistent network settings.""" +from __future__ import annotations + +import logging +from typing import Any + +from zigpy.backups import NetworkBackup + +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import issue_registry as ir + +from ..core.const import DOMAIN +from ..radio_manager import ZhaRadioManager + +_LOGGER = logging.getLogger(__name__) + +ISSUE_INCONSISTENT_NETWORK_SETTINGS = "inconsistent_network_settings" + + +def _format_settings_diff(old_state: NetworkBackup, new_state: NetworkBackup) -> str: + """Format the difference between two network backups.""" + lines: list[str] = [] + + def _add_difference( + lines: list[str], text: str, old: Any, new: Any, pre: bool = True + ) -> None: + """Add a line to the list if the values are different.""" + wrap = "`" if pre else "" + + if old != new: + lines.append(f"{text}: {wrap}{old}{wrap} \u2192 {wrap}{new}{wrap}") + + _add_difference( + lines, + "Channel", + old=old_state.network_info.channel, + new=new_state.network_info.channel, + pre=False, + ) + _add_difference( + lines, + "Node IEEE", + old=old_state.node_info.ieee, + new=new_state.node_info.ieee, + ) + _add_difference( + lines, + "PAN ID", + old=old_state.network_info.pan_id, + new=new_state.network_info.pan_id, + ) + _add_difference( + lines, + "Extended PAN ID", + old=old_state.network_info.extended_pan_id, + new=new_state.network_info.extended_pan_id, + ) + _add_difference( + lines, + "NWK update ID", + old=old_state.network_info.nwk_update_id, + new=new_state.network_info.nwk_update_id, + pre=False, + ) + _add_difference( + lines, + "TC Link Key", + old=old_state.network_info.tc_link_key.key, + new=new_state.network_info.tc_link_key.key, + ) + _add_difference( + lines, + "Network Key", + old=old_state.network_info.network_key.key, + new=new_state.network_info.network_key.key, + ) + + return "\n".join([f"- {line}" for line in lines]) + + +async def warn_on_inconsistent_network_settings( + hass: HomeAssistant, + config_entry: ConfigEntry, + old_state: NetworkBackup, + new_state: NetworkBackup, +) -> None: + """Create a repair if the network settings are inconsistent with the last backup.""" + + ir.async_create_issue( + hass, + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + is_fixable=True, + severity=ir.IssueSeverity.ERROR, + translation_key=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + data={ + "config_entry_id": config_entry.entry_id, + "old_state": old_state.as_dict(), + "new_state": new_state.as_dict(), + }, + ) + + +class NetworkSettingsInconsistentFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, hass: HomeAssistant, data: dict[str, Any]) -> None: + """Initialize the flow.""" + self.hass = hass + self._old_state = NetworkBackup.from_dict(data["old_state"]) + self._new_state = NetworkBackup.from_dict(data["new_state"]) + + self._entry_id: str = data["config_entry_id"] + + config_entry = self.hass.config_entries.async_get_entry(self._entry_id) + assert config_entry is not None + self._radio_mgr = ZhaRadioManager.from_config_entry(self.hass, config_entry) + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Handle the first step of a fix flow.""" + return self.async_show_menu( + step_id="init", + menu_options=["restore_old_settings", "use_new_settings"], + description_placeholders={ + "diff": _format_settings_diff(self._old_state, self._new_state) + }, + ) + + async def async_step_use_new_settings( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Step to use the new settings found on the radio.""" + async with self._radio_mgr.connect_zigpy_app() as app: + app.backups.add_backup(self._new_state) + + await self.hass.config_entries.async_reload(self._entry_id) + return self.async_create_entry(title="", data={}) + + async def async_step_restore_old_settings( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Step to restore the most recent backup.""" + await self._radio_mgr.restore_backup(self._old_state) + + await self.hass.config_entries.async_reload(self._entry_id) + return self.async_create_entry(title="", data={}) diff --git a/homeassistant/components/zha/repairs.py b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py similarity index 93% rename from homeassistant/components/zha/repairs.py rename to homeassistant/components/zha/repairs/wrong_silabs_firmware.py index ac523f37aa0..93c5489eda7 100644 --- a/homeassistant/components/zha/repairs.py +++ b/homeassistant/components/zha/repairs/wrong_silabs_firmware.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir -from .core.const import DOMAIN +from ..core.const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -119,8 +119,3 @@ async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> boo ) return True - - -def async_delete_blocking_issues(hass: HomeAssistant) -> None: - """Delete repair issues that should disappear on a successful startup.""" - ir.async_delete_issue(hass, DOMAIN, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 79354325fb2..21bf95f7ce6 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -513,6 +513,21 @@ "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). To run your radio exclusively with ZHA, you need to install Zigbee firmware. Follow your Zigbee radio manufacturer's instructions for how to do this." + }, + "inconsistent_network_settings": { + "title": "Zigbee network settings have changed", + "fix_flow": { + "step": { + "init": { + "title": "[%key:component::zha::issues::inconsistent_network_settings::title%]", + "description": "Your Zigbee radio's network settings are inconsistent with the most recent network backup. This usually happens if another Zigbee integration (e.g. Zigbee2MQTT or deCONZ) has overwritten them.\n\n{diff}\n\nIf you did not intentionally change your network settings, restore from the most recent backup: your devices will not work otherwise.", + "menu_options": { + "use_new_settings": "Keep the new settings", + "restore_old_settings": "Restore backup (recommended)" + } + } + } + } } } } diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e7dc7316f73..9d9d74e72df 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -2,7 +2,9 @@ from collections.abc import Callable, Generator import itertools import time -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock, create_autospec, patch +import warnings import pytest import zigpy @@ -14,6 +16,7 @@ import zigpy.device import zigpy.group import zigpy.profiles import zigpy.quirks +import zigpy.state import zigpy.types import zigpy.util from zigpy.zcl.clusters.general import Basic, Groups @@ -92,7 +95,9 @@ class _FakeApp(ControllerApplication): async def start_network(self): pass - async def write_network_info(self): + async def write_network_info( + self, *, network_info: zigpy.state.NetworkInfo, node_info: zigpy.state.NodeInfo + ) -> None: pass async def request( @@ -111,9 +116,33 @@ class _FakeApp(ControllerApplication): ): pass + async def move_network_to_channel( + self, new_channel: int, *, num_broadcasts: int = 5 + ) -> None: + pass + + +def _wrap_mock_instance(obj: Any) -> MagicMock: + """Auto-mock every attribute and method in an object.""" + mock = create_autospec(obj, spec_set=True, instance=True) + + for attr_name in dir(obj): + if attr_name.startswith("__") and attr_name not in {"__getitem__"}: + continue + + real_attr = getattr(obj, attr_name) + mock_attr = getattr(mock, attr_name) + + if callable(real_attr): + mock_attr.side_effect = real_attr + else: + setattr(mock, attr_name, real_attr) + + return mock + @pytest.fixture -def zigpy_app_controller(): +async def zigpy_app_controller(): """Zigpy ApplicationController fixture.""" app = _FakeApp( { @@ -145,14 +174,14 @@ def zigpy_app_controller(): ep.add_input_cluster(Basic.cluster_id) ep.add_input_cluster(Groups.cluster_id) - with patch( - "zigpy.device.Device.request", return_value=[Status.SUCCESS] - ), patch.object(app, "permit", autospec=True), patch.object( - app, "startup", wraps=app.startup - ), patch.object( - app, "permit_with_key", autospec=True - ): - yield app + with patch("zigpy.device.Device.request", return_value=[Status.SUCCESS]): + # The mock wrapping accesses deprecated attributes, so we suppress the warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mock_app = _wrap_mock_instance(app) + mock_app.backups = _wrap_mock_instance(app.backups) + + yield mock_app @pytest.fixture(name="config_entry") @@ -189,12 +218,17 @@ def mock_zigpy_connect( with patch( "bellows.zigbee.application.ControllerApplication.new", return_value=zigpy_app_controller, - ) as mock_app: - yield mock_app + ), patch( + "bellows.zigbee.application.ControllerApplication", + return_value=zigpy_app_controller, + ): + yield zigpy_app_controller @pytest.fixture -def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): +def setup_zha( + hass, config_entry: MockConfigEntry, mock_zigpy_connect: ControllerApplication +): """Set up ZHA component.""" zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} @@ -202,12 +236,11 @@ def setup_zha(hass, config_entry: MockConfigEntry, mock_zigpy_connect): config_entry.add_to_hass(hass) config = config or {} - with mock_zigpy_connect: - status = await async_setup_component( - hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} - ) - assert status is True - await hass.async_block_till_done() + status = await async_setup_component( + hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} + ) + assert status is True + await hass.async_block_till_done() return _setup @@ -394,3 +427,74 @@ def speed_up_radio_mgr(): """Speed up the radio manager connection time by removing delays.""" with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.00001): yield + + +@pytest.fixture +def network_backup() -> zigpy.backups.NetworkBackup: + """Real ZHA network backup taken from an active instance.""" + return zigpy.backups.NetworkBackup.from_dict( + { + "backup_time": "2022-11-16T03:16:49.427675+00:00", + "network_info": { + "extended_pan_id": "2f:73:58:bd:fe:78:91:11", + "pan_id": "2DB4", + "nwk_update_id": 0, + "nwk_manager_id": "0000", + "channel": 15, + "channel_mask": [ + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + ], + "security_level": 5, + "network_key": { + "key": "4a:c7:9d:50:51:09:16:37:2e:34:66:c6:ed:9b:23:85", + "tx_counter": 14131, + "rx_counter": 0, + "seq": 0, + "partner_ieee": "ff:ff:ff:ff:ff:ff:ff:ff", + }, + "tc_link_key": { + "key": "5a:69:67:42:65:65:41:6c:6c:69:61:6e:63:65:30:39", + "tx_counter": 0, + "rx_counter": 0, + "seq": 0, + "partner_ieee": "84:ba:20:ff:fe:59:f5:ff", + }, + "key_table": [], + "children": [], + "nwk_addresses": {"cc:cc:cc:ff:fe:e6:8e:ca": "1431"}, + "stack_specific": { + "ezsp": {"hashed_tclk": "e9bd3ac165233d95923613c608beb147"} + }, + "metadata": { + "ezsp": { + "manufacturer": "", + "board": "", + "version": "7.1.3.0 build 0", + "stack_version": 9, + "can_write_custom_eui64": False, + } + }, + "source": "bellows@0.34.2", + }, + "node_info": { + "nwk": "0000", + "ieee": "84:ba:20:ff:fe:59:f5:ff", + "logical_type": "coordinator", + }, + } + ) diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 89742fb1e49..c3dac0ddd8c 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -2,7 +2,7 @@ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch import pytest import zigpy.backups @@ -48,21 +48,18 @@ async def test_async_get_network_settings_inactive( backup.network_info.channel = 20 zigpy_app_controller.backups.backups.append(backup) - with patch( - "bellows.zigbee.application.ControllerApplication.__new__", - return_value=zigpy_app_controller, - ), patch.object( - zigpy_app_controller, "_load_db", wraps=zigpy_app_controller._load_db - ) as mock_load_db, patch.object( - zigpy_app_controller, - "start_network", - wraps=zigpy_app_controller.start_network, - ) as mock_start_network: + controller = AsyncMock() + controller.SCHEMA = zigpy_app_controller.SCHEMA + controller.new = AsyncMock(return_value=zigpy_app_controller) + + with patch.dict( + "homeassistant.components.zha.core.const.RadioType._member_map_", + ezsp=MagicMock(controller=controller, description="EZSP"), + ): settings = await api.async_get_network_settings(hass) - assert len(mock_load_db.mock_calls) == 1 - assert len(mock_start_network.mock_calls) == 0 assert settings.network_info.channel == 20 + assert len(zigpy_app_controller.start_network.mock_calls) == 0 async def test_async_get_network_settings_missing( @@ -78,11 +75,7 @@ async def test_async_get_network_settings_missing( zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() - with patch( - "bellows.zigbee.application.ControllerApplication.__new__", - return_value=zigpy_app_controller, - ): - settings = await api.async_get_network_settings(hass) + settings = await api.async_get_network_settings(hass) assert settings is None @@ -115,12 +108,8 @@ async def test_change_channel( """Test changing the channel.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel: - await api.async_change_channel(hass, 20) - - assert mock_move_network_to_channel.mock_calls == [call(20)] + await api.async_change_channel(hass, 20) + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] async def test_change_channel_auto( @@ -129,16 +118,10 @@ async def test_change_channel_auto( """Test changing the channel automatically using an energy scan.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel, patch.object( - zigpy_app_controller, - "energy_scan", - autospec=True, - return_value={c: c for c in range(11, 26 + 1)}, - ), patch.object( - api, "pick_optimal_channel", autospec=True, return_value=25 - ): + zigpy_app_controller.energy_scan.side_effect = None + zigpy_app_controller.energy_scan.return_value = {c: c for c in range(11, 26 + 1)} + + with patch.object(api, "pick_optimal_channel", autospec=True, return_value=25): await api.async_change_channel(hass, "auto") - assert mock_move_network_to_channel.mock_calls == [call(25)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(25)] diff --git a/tests/components/zha/test_backup.py b/tests/components/zha/test_backup.py index 9ce692b41ae..bee00c5a587 100644 --- a/tests/components/zha/test_backup.py +++ b/tests/components/zha/test_backup.py @@ -1,17 +1,24 @@ """Unit tests for ZHA backup platform.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock + +from zigpy.application import ControllerApplication from homeassistant.components.zha.backup import async_post_backup, async_pre_backup from homeassistant.core import HomeAssistant -async def test_pre_backup(hass: HomeAssistant, setup_zha) -> None: +async def test_pre_backup( + hass: HomeAssistant, zigpy_app_controller: ControllerApplication, setup_zha +) -> None: """Test backup creation when `async_pre_backup` is called.""" - with patch("zigpy.backups.BackupManager.create_backup", AsyncMock()) as backup_mock: - await setup_zha() - await async_pre_backup(hass) + await setup_zha() - backup_mock.assert_called_once_with(load_devices=True) + zigpy_app_controller.backups.create_backup = AsyncMock() + await async_pre_backup(hass) + + zigpy_app_controller.backups.create_backup.assert_called_once_with( + load_devices=True + ) async def test_post_backup(hass: HomeAssistant, setup_zha) -> None: diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 096d83567fe..c3563872873 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -4,6 +4,7 @@ import time from unittest.mock import patch import pytest +from zigpy.application import ControllerApplication import zigpy.profiles.zha import zigpy.zcl.clusters.general as general @@ -408,7 +409,7 @@ async def test_validate_trigger_config_missing_info( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, zha_device_joined, caplog: pytest.LogCaptureFixture, ) -> None: @@ -461,7 +462,7 @@ async def test_validate_trigger_config_unloaded_bad_info( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, zha_device_joined, caplog: pytest.LogCaptureFixture, ) -> None: diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 214bfcad9f0..2a0a241c864 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -9,6 +9,7 @@ import zigpy.profiles.zha as zha import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting +from homeassistant.components.zha.core.const import RadioType from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.core.helpers import get_zha_gateway @@ -350,13 +351,11 @@ async def test_gateway_initialize_bellows_thread( zha_gateway.config_entry.data["device"]["path"] = device_path zha_gateway._config.setdefault("zigpy_config", {}).update(config_override) - with patch( - "bellows.zigbee.application.ControllerApplication.new", - return_value=zigpy_app_controller, - ) as mock_new: - await zha_gateway.async_initialize() + await zha_gateway.async_initialize() - assert mock_new.mock_calls[0].kwargs["config"]["use_thread"] is thread_state + RadioType.ezsp.controller.new.mock_calls[-1].kwargs["config"][ + "use_thread" + ] is thread_state @pytest.mark.parametrize( diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 6bac012d667..fc1e6611692 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -3,6 +3,7 @@ import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest +from zigpy.application import ControllerApplication from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import TransientConnectionError @@ -136,7 +137,10 @@ async def test_config_depreciation(hass: HomeAssistant, zha_config) -> None: "homeassistant.components.zha.websocket_api.async_load_api", Mock(return_value=True) ) async def test_setup_with_v3_cleaning_uri( - hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect + hass: HomeAssistant, + path: str, + cleaned_path: str, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( @@ -166,7 +170,7 @@ async def test_zha_retry_unique_ids( hass: HomeAssistant, config_entry: MockConfigEntry, zigpy_device_mock, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, caplog, ) -> None: """Test that ZHA retrying creates unique entity IDs.""" @@ -174,7 +178,7 @@ async def test_zha_retry_unique_ids( config_entry.add_to_hass(hass) # Ensure we have some device to try to load - app = mock_zigpy_connect.return_value + app = mock_zigpy_connect light = zigpy_device_mock(LIGHT_ON_OFF) app.devices[light.ieee] = light diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 1467e2e2951..67f2d0164d3 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -456,7 +456,7 @@ async def test_detect_radio_type_failure_wrong_firmware( with patch( "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () ), patch( - "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + "homeassistant.components.zha.radio_manager.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware", return_value=True, ): assert ( @@ -473,7 +473,7 @@ async def test_detect_radio_type_failure_no_detect( with patch( "homeassistant.components.zha.radio_manager.AUTOPROBE_RADIOS", () ), patch( - "homeassistant.components.zha.radio_manager.repairs.warn_on_wrong_silabs_firmware", + "homeassistant.components.zha.radio_manager.repairs.wrong_silabs_firmware.warn_on_wrong_silabs_firmware", return_value=False, ): assert await radio_manager.detect_radio_type() == ProbeResult.PROBING_FAILED diff --git a/tests/components/zha/test_repairs.py b/tests/components/zha/test_repairs.py index 18705168a3f..9c79578843c 100644 --- a/tests/components/zha/test_repairs.py +++ b/tests/components/zha/test_repairs.py @@ -1,17 +1,25 @@ """Test ZHA repairs.""" from collections.abc import Callable +from http import HTTPStatus import logging -from unittest.mock import patch +from unittest.mock import Mock, call, patch import pytest from universal_silabs_flasher.const import ApplicationType from universal_silabs_flasher.flasher import Flasher +from zigpy.application import ControllerApplication +import zigpy.backups +from zigpy.exceptions import NetworkSettingsInconsistent from homeassistant.components.homeassistant_sky_connect import ( DOMAIN as SKYCONNECT_DOMAIN, ) +from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.components.zha.core.const import DOMAIN -from homeassistant.components.zha.repairs import ( +from homeassistant.components.zha.repairs.network_settings_inconsistent import ( + ISSUE_INCONSISTENT_NETWORK_SETTINGS, +) +from homeassistant.components.zha.repairs.wrong_silabs_firmware import ( DISABLE_MULTIPAN_URL, ISSUE_WRONG_SILABS_FIRMWARE_INSTALLED, HardwareType, @@ -23,8 +31,10 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +from tests.typing import ClientSessionGenerator SKYCONNECT_DEVICE = "/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0" @@ -98,7 +108,7 @@ async def test_multipan_firmware_repair( detected_hardware: HardwareType, expected_learn_more_url: str, config_entry: MockConfigEntry, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test creating a repair when multi-PAN firmware is installed and probed.""" @@ -106,14 +116,14 @@ async def test_multipan_firmware_repair( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(ApplicationType.CPC), autospec=True, ), patch( "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", side_effect=RuntimeError(), ), patch( - "homeassistant.components.zha.repairs._detect_radio_hardware", + "homeassistant.components.zha.repairs.wrong_silabs_firmware._detect_radio_hardware", return_value=detected_hardware, ): await hass.config_entries.async_setup(config_entry.entry_id) @@ -136,9 +146,8 @@ async def test_multipan_firmware_repair( assert issue.learn_more_url == expected_learn_more_url # If ZHA manages to start up normally after this, the issue will be deleted - with mock_zigpy_connect: - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() issue = issue_registry.async_get_issue( domain=DOMAIN, @@ -156,7 +165,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(None), autospec=True, ), patch( @@ -182,7 +191,7 @@ async def test_multipan_firmware_no_repair_on_probe_failure( async def test_multipan_firmware_retry_on_probe_ezsp( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_zigpy_connect, + mock_zigpy_connect: ControllerApplication, ) -> None: """Test that ZHA is reloaded when EZSP firmware is probed.""" @@ -190,7 +199,7 @@ async def test_multipan_firmware_retry_on_probe_ezsp( # ZHA fails to set up with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=set_flasher_app_type(ApplicationType.EZSP), autospec=True, ), patch( @@ -217,7 +226,8 @@ async def test_multipan_firmware_retry_on_probe_ezsp( async def test_no_warn_on_socket(hass: HomeAssistant) -> None: """Test that no warning is issued when the device is a socket.""" with patch( - "homeassistant.components.zha.repairs.probe_silabs_firmware_type", autospec=True + "homeassistant.components.zha.repairs.wrong_silabs_firmware.probe_silabs_firmware_type", + autospec=True, ) as mock_probe: await warn_on_wrong_silabs_firmware(hass, device="socket://1.2.3.4:5678") @@ -227,9 +237,163 @@ async def test_no_warn_on_socket(hass: HomeAssistant) -> None: async def test_probe_failure_exception_handling(caplog) -> None: """Test that probe failures are handled gracefully.""" with patch( - "homeassistant.components.zha.repairs.Flasher.probe_app_type", + "homeassistant.components.zha.repairs.wrong_silabs_firmware.Flasher.probe_app_type", side_effect=RuntimeError(), ), caplog.at_level(logging.DEBUG): await probe_silabs_firmware_type("/dev/ttyZigbee") assert "Failed to probe application type" in caplog.text + + +async def test_inconsistent_settings_keep_new( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, + network_backup: zigpy.backups.NetworkBackup, +) -> None: + """Test inconsistent ZHA network settings: keep new settings.""" + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + config_entry.add_to_hass(hass) + + new_state = network_backup.replace( + network_info=network_backup.network_info.replace(pan_id=0xBBBB) + ) + old_state = network_backup + + with patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=NetworkSettingsInconsistent( + message="Network settings are inconsistent", + new_state=new_state, + old_state=old_state, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + + # The issue is created + assert issue is not None + + client = await hass_client() + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`" + + mock_zigpy_connect.backups.add_backup = Mock() + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "use_new_settings"}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + is None + ) + + assert mock_zigpy_connect.backups.add_backup.mock_calls == [call(new_state)] + + +async def test_inconsistent_settings_restore_old( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, + network_backup: zigpy.backups.NetworkBackup, +) -> None: + """Test inconsistent ZHA network settings: restore last backup.""" + + assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) + + config_entry.add_to_hass(hass) + + new_state = network_backup.replace( + network_info=network_backup.network_info.replace(pan_id=0xBBBB) + ) + old_state = network_backup + + with patch( + "homeassistant.components.zha.core.gateway.ZHAGateway.async_initialize", + side_effect=NetworkSettingsInconsistent( + message="Network settings are inconsistent", + new_state=new_state, + old_state=old_state, + ), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.SETUP_ERROR + + await hass.config_entries.async_unload(config_entry.entry_id) + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + + # The issue is created + assert issue is not None + + client = await hass_client() + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": DOMAIN, "issue_id": issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["description_placeholders"]["diff"] == "- PAN ID: `0x2DB4` → `0xBBBB`" + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "restore_old_settings"}, + ) + await hass.async_block_till_done() + + assert resp.status == HTTPStatus.OK + data = await resp.json() + assert data["type"] == "create_entry" + + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=ISSUE_INCONSISTENT_NETWORK_SETTINGS, + ) + is None + ) + + assert mock_zigpy_connect.backups.restore_backup.mock_calls == [call(old_state)] diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index 4d11ae81b08..074484e6d24 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -44,11 +44,7 @@ async def test_async_get_channel_missing( zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() - with patch( - "bellows.zigbee.application.ControllerApplication.__new__", - return_value=zigpy_app_controller, - ): - assert await silabs_multiprotocol.async_get_channel(hass) is None + assert await silabs_multiprotocol.async_get_channel(hass) is None async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: @@ -74,26 +70,20 @@ async def test_change_channel( """Test changing the channel.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel: - task = await silabs_multiprotocol.async_change_channel(hass, 20) - await task + task = await silabs_multiprotocol.async_change_channel(hass, 20) + await task - assert mock_move_network_to_channel.mock_calls == [call(20)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] async def test_change_channel_no_zha( hass: HomeAssistant, zigpy_app_controller: ControllerApplication ) -> None: """Test changing the channel with no ZHA config entries and no database.""" - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel: - task = await silabs_multiprotocol.async_change_channel(hass, 20) + task = await silabs_multiprotocol.async_change_channel(hass, 20) assert task is None - assert mock_move_network_to_channel.mock_calls == [] + assert zigpy_app_controller.mock_calls == [] @pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) @@ -107,13 +97,11 @@ async def test_change_channel_delay( """Test changing the channel with a delay.""" await setup_zha() - with patch.object( - zigpy_app_controller, "move_network_to_channel", autospec=True - ) as mock_move_network_to_channel, patch( + with patch( "homeassistant.components.zha.silabs_multiprotocol.asyncio.sleep", autospec=True ) as mock_sleep: task = await silabs_multiprotocol.async_change_channel(hass, 20, delay=delay) await task - assert mock_move_network_to_channel.mock_calls == [call(20)] + assert zigpy_app_controller.move_network_to_channel.mock_calls == [call(20)] assert mock_sleep.mock_calls == [call(sleep)] From d46dca895027691c7dc59f4bcb4fedcb881abc11 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 9 Oct 2023 15:23:25 +0200 Subject: [PATCH 318/968] Bump aiocomelit to 0.2.0 (#101586) --- homeassistant/components/comelit/__init__.py | 12 +++++++--- .../components/comelit/config_flow.py | 23 ++++++++++++++----- homeassistant/components/comelit/const.py | 5 ++++ .../components/comelit/coordinator.py | 13 +++++------ homeassistant/components/comelit/cover.py | 8 +++---- homeassistant/components/comelit/light.py | 14 +++++------ .../components/comelit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/const.py | 6 +++-- tests/components/comelit/test_config_flow.py | 9 ++++++-- 11 files changed, 62 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 4a105072802..28d87f5b284 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -1,10 +1,11 @@ """Comelit integration.""" + from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PIN, Platform +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitSerialBridge PLATFORMS = [Platform.COVER, Platform.LIGHT] @@ -12,7 +13,12 @@ PLATFORMS = [Platform.COVER, Platform.LIGHT] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Comelit platform.""" - coordinator = ComelitSerialBridge(hass, entry.data[CONF_HOST], entry.data[CONF_PIN]) + coordinator = ComelitSerialBridge( + hass, + entry.data[CONF_HOST], + entry.data.get(CONF_PORT, DEFAULT_PORT), + entry.data[CONF_PIN], + ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index b0c8e5aabe5..66ab9ae88b3 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -9,10 +9,11 @@ import voluptuous as vol from homeassistant import core, exceptions from homeassistant.config_entries import ConfigEntry, ConfigFlow -from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import _LOGGER, DOMAIN +from .const import _LOGGER, DEFAULT_PORT, DOMAIN DEFAULT_HOST = "192.168.1.252" DEFAULT_PIN = "111111" @@ -23,8 +24,9 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: user_input = user_input or {} return vol.Schema( { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): str, - vol.Optional(CONF_PIN, default=DEFAULT_PIN): str, + vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int, } ) @@ -37,7 +39,7 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PIN]) + api = ComeliteSerialBridgeApi(data[CONF_HOST], data[CONF_PORT], data[CONF_PIN]) try: await api.login() @@ -58,6 +60,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 _reauth_entry: ConfigEntry | None _reauth_host: str + _reauth_port: int async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -94,6 +97,8 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): self.context["entry_id"] ) self._reauth_host = entry_data[CONF_HOST] + self._reauth_port = entry_data.get(CONF_PORT, DEFAULT_PORT) + self.context["title_placeholders"] = {"host": self._reauth_host} return await self.async_step_reauth_confirm() @@ -107,7 +112,12 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: try: await validate_input( - self.hass, {CONF_HOST: self._reauth_host} | user_input + self.hass, + { + CONF_HOST: self._reauth_host, + CONF_PORT: self._reauth_port, + } + | user_input, ) except CannotConnect: errors["base"] = "cannot_connect" @@ -121,6 +131,7 @@ class ComelitConfigFlow(ConfigFlow, domain=DOMAIN): self._reauth_entry, data={ CONF_HOST: self._reauth_host, + CONF_PORT: self._reauth_port, CONF_PIN: user_input[CONF_PIN], }, ) diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index e08caa55f76..7bd49440eb3 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -4,3 +4,8 @@ import logging _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" +DEFAULT_PORT = 80 + +# Entity states +STATE_OFF = 0 +STATE_ON = 1 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index a9c281c10c0..02a8d805d19 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -21,13 +21,14 @@ class ComelitSerialBridge(DataUpdateCoordinator): config_entry: ConfigEntry - def __init__(self, hass: HomeAssistant, host: str, pin: int) -> None: + def __init__(self, hass: HomeAssistant, host: str, port: int, pin: int) -> None: """Initialize the scanner.""" self._host = host + self._port = port self._pin = pin - self.api = ComeliteSerialBridgeApi(host, pin) + self.api = ComeliteSerialBridgeApi(host, port, pin) super().__init__( hass=hass, @@ -53,18 +54,16 @@ class ComelitSerialBridge(DataUpdateCoordinator): "hw_version": "20003101", } - def platform_device_info( - self, device: ComelitSerialBridgeObject, platform: str - ) -> dr.DeviceInfo: + def platform_device_info(self, device: ComelitSerialBridgeObject) -> dr.DeviceInfo: """Set platform device info.""" return dr.DeviceInfo( identifiers={ - (DOMAIN, f"{self.config_entry.entry_id}-{platform}-{device.index}") + (DOMAIN, f"{self.config_entry.entry_id}-{device.type}-{device.index}") }, via_device=(DOMAIN, self.config_entry.entry_id), name=device.name, - model=f"{BRIDGE} {platform}", + model=f"{BRIDGE} {device.type}", **self.basic_device_info, ) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 48478f075d3..08e1136ca9e 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -53,7 +53,7 @@ class ComelitCoverEntity( self._device = device super().__init__(coordinator) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = coordinator.platform_device_info(device, COVER) + self._attr_device_info = coordinator.platform_device_info(device) # Device doesn't provide a status so we assume UNKNOWN at first startup self._last_action: int | None = None self._last_state: str | None = None @@ -97,11 +97,11 @@ class ComelitCoverEntity( async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.cover_move(self._device.index, COVER_CLOSE) + await self._api.set_device_status(COVER, self._device.index, COVER_CLOSE) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.cover_move(self._device.index, COVER_OPEN) + await self._api.set_device_status(COVER, self._device.index, COVER_OPEN) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" @@ -109,7 +109,7 @@ class ComelitCoverEntity( return action = COVER_OPEN if self.is_closing else COVER_CLOSE - await self._api.cover_move(self._device.index, action) + await self._api.set_device_status(COVER, self._device.index, action) @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index a59422f7b04..30981cd2820 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import LIGHT, LIGHT_OFF, LIGHT_ON +from aiocomelit.const import LIGHT from homeassistant.components.light import LightEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN +from .const import DOMAIN, STATE_OFF, STATE_ON from .coordinator import ComelitSerialBridge @@ -49,22 +49,22 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._device = device super().__init__(coordinator) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = self.coordinator.platform_device_info(device, LIGHT) + self._attr_device_info = self.coordinator.platform_device_info(device) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" - await self.coordinator.api.light_switch(self._device.index, state) + await self.coordinator.api.set_device_status(LIGHT, self._device.index, state) await self.coordinator.async_request_refresh() async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - await self._light_set_state(LIGHT_ON) + await self._light_set_state(STATE_ON) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - await self._light_set_state(LIGHT_OFF) + await self._light_set_state(STATE_OFF) @property def is_on(self) -> bool: """Return True if entity is on.""" - return self.coordinator.data[LIGHT][self._device.index].status == LIGHT_ON + return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 3e49996e50e..7da4f70ce99 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.0.9"] + "requirements": ["aiocomelit==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e130adfe58d..a9cd582a6ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -210,7 +210,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.9 +aiocomelit==0.2.0 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ed5cec5beb..6e56127849f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.0.9 +aiocomelit==0.2.0 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index 36955b0b0a9..a21ddbd425a 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -1,13 +1,15 @@ """Common stuff for Comelit SimpleHome tests.""" + from homeassistant.components.comelit.const import DOMAIN -from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN +from homeassistant.const import CONF_DEVICES, CONF_HOST, CONF_PIN, CONF_PORT MOCK_CONFIG = { DOMAIN: { CONF_DEVICES: [ { CONF_HOST: "fake_host", - CONF_PIN: "1234", + CONF_PORT: 80, + CONF_PIN: 1234, } ] } diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 10f68f4d7c1..4e3831809cb 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -6,7 +6,7 @@ import pytest from homeassistant.components.comelit.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_PIN +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -39,7 +39,8 @@ async def test_user(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_PIN] == "1234" + assert result["data"][CONF_PORT] == 80 + assert result["data"][CONF_PIN] == 1234 assert not result["result"].unique_id await hass.async_block_till_done() @@ -66,6 +67,10 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> with patch( "aiocomelit.api.ComeliteSerialBridgeApi.login", side_effect=side_effect, + ), patch( + "aiocomelit.api.ComeliteSerialBridgeApi.logout", + ), patch( + "homeassistant.components.comelit.async_setup_entry" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA From 70a0cd579d0af374a3a5ee15c79c59bdef1b5b5b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 9 Oct 2023 13:33:31 -0400 Subject: [PATCH 319/968] Add Z-Wave WS command to hard reset controller (#101449) --- homeassistant/components/zwave_js/api.py | 24 +++++++++ tests/components/zwave_js/test_api.py | 69 ++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 8658dc1cc1f..0e7c36c479d 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -447,6 +447,7 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) + websocket_api.async_register_command(hass, websocket_hard_reset_controller) hass.http.register_view(FirmwareUploadView(dr.async_get(hass))) @@ -2441,3 +2442,26 @@ async def websocket_subscribe_node_statistics( }, ) ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/hard_reset_controller", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_hard_reset_controller( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict[str, Any], + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Hard reset controller.""" + await driver.async_hard_reset() + connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 965b1ea4f1b..4ff7c481e37 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4650,3 +4650,72 @@ async def test_subscribe_node_statistics( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED + + +async def test_hard_reset_controller( + hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator +) -> None: + """Test that the hard_reset_controller WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["result"] is None + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == {"command": "driver.hard_reset"} + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.driver.Driver.async_hard_reset", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "zwave_error: Z-Wave error 1 - error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/hard_reset_controller", + ENTRY_ID: "INVALID", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND From 84fe356782baa14e138e6804cb5cd25893f98c3f Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 9 Oct 2023 19:43:47 +0200 Subject: [PATCH 320/968] Fix sky connect tests (#101712) * Fix sky connect tests * Implement suggestions --- tests/components/homeassistant_sky_connect/test_config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9e1977192e9..4d43d29463a 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -7,7 +7,7 @@ import pytest from homeassistant.components import homeassistant_sky_connect, usb from homeassistant.components.homeassistant_sky_connect.const import DOMAIN -from homeassistant.components.zha.core.const import ( +from homeassistant.components.zha import ( CONF_DEVICE_PATH, DOMAIN as ZHA_DOMAIN, RadioType, From 66f43ebdc53541ce5e2a1b3969b254606ef9d063 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 9 Oct 2023 19:52:24 +0200 Subject: [PATCH 321/968] Describe notification option (philip_js) (#101715) --- .../components/philips_js/config_flow.py | 43 +++++++------------ .../components/philips_js/strings.json | 5 ++- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/philips_js/config_flow.py b/homeassistant/components/philips_js/config_flow.py index 9b7e52c2119..d1cd3e7b1a5 100644 --- a/homeassistant/components/philips_js/config_flow.py +++ b/homeassistant/components/philips_js/config_flow.py @@ -17,6 +17,11 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import selector +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaFlowFormStep, + SchemaOptionsFlowHandler, +) from . import LOGGER from .const import CONF_ALLOW_NOTIFY, CONF_SYSTEM, CONST_APP_ID, CONST_APP_NAME, DOMAIN @@ -33,6 +38,15 @@ USER_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ALLOW_NOTIFY, default=False): selector.BooleanSelector(), + } +) +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(OPTIONS_SCHEMA), +} + async def _validate_input( hass: core.HomeAssistant, host: str, api_version: int @@ -176,31 +190,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @core.callback def async_get_options_flow( config_entry: config_entries.ConfigEntry, - ) -> OptionsFlowHandler: + ) -> SchemaOptionsFlowHandler: """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for AEMET.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - data_schema = vol.Schema( - { - vol.Required( - CONF_ALLOW_NOTIFY, - default=self.config_entry.options.get(CONF_ALLOW_NOTIFY), - ): bool, - } - ) - return self.async_show_form(step_id="init", data_schema=data_schema) + return SchemaOptionsFlowHandler(config_entry, OPTIONS_FLOW) diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index a260d42feda..19228e906d9 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -30,7 +30,10 @@ "step": { "init": { "data": { - "allow_notify": "Allow usage of data notification service." + "allow_notify": "Allow notification service" + }, + "data_description": { + "allow_notify": "Allow the usage of data notification service on TV instead of periodic polling. This allow faster reaction to state changes on the TV, however, some TV's will stop responding when this activated due to firmware bugs." } } } From a21990f2488e7d2fc1e0b128de26ccee5387cbfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Oct 2023 21:07:16 +0200 Subject: [PATCH 322/968] Update pytest warnings filter (#101710) --- pyproject.toml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a8c41d2912e..6a41c3c355e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -469,10 +469,12 @@ filterwarnings = [ "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", - # https://github.com/poljar/matrix-nio/pull/438 - >0.21.2 + # https://github.com/poljar/matrix-nio/pull/438 - >=0.22.0 "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", - # https://github.com/poljar/matrix-nio/pull/439 - >0.21.2 + # https://github.com/poljar/matrix-nio/pull/439 - >=0.22.0 "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", + # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 + "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", @@ -487,6 +489,8 @@ filterwarnings = [ # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", + # New in aiohttp - v3.9.0 + "ignore:It is recommended to use web.AppKey instances for keys:UserWarning:(homeassistant|tests|aiohttp_cors)", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 From 35293eb98bc296e62d7739e0d86271ac84d6e191 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 9 Oct 2023 23:23:45 +0200 Subject: [PATCH 323/968] Update matrix-nio to 0.22.1 (#101693) --- homeassistant/components/matrix/manifest.json | 2 +- pyproject.toml | 4 ---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 69d059fdce5..5e1f6fa111c 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.1"] + "requirements": ["matrix-nio==0.22.1", "Pillow==10.0.1"] } diff --git a/pyproject.toml b/pyproject.toml index 6a41c3c355e..9b8642172ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -469,10 +469,6 @@ filterwarnings = [ "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", - # https://github.com/poljar/matrix-nio/pull/438 - >=0.22.0 - "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", - # https://github.com/poljar/matrix-nio/pull/439 - >=0.22.0 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 diff --git a/requirements_all.txt b/requirements_all.txt index a9cd582a6ea..46aaccda8f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1183,7 +1183,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.21.2 +matrix-nio==0.22.1 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e56127849f..0657e650bed 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -915,7 +915,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-nio==0.21.2 +matrix-nio==0.22.1 # homeassistant.components.maxcube maxcube-api==0.4.3 From db3a4dec3378cddf59982491681e61ce4f8ba921 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Mon, 9 Oct 2023 20:28:39 -0400 Subject: [PATCH 324/968] Update eufylife-ble-client to 0.1.8 (#101727) --- homeassistant/components/eufylife_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/eufylife_ble/manifest.json b/homeassistant/components/eufylife_ble/manifest.json index c3a2357ebca..efafaa971e8 100644 --- a/homeassistant/components/eufylife_ble/manifest.json +++ b/homeassistant/components/eufylife_ble/manifest.json @@ -24,5 +24,5 @@ "documentation": "https://www.home-assistant.io/integrations/eufylife_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["eufylife-ble-client==0.1.7"] + "requirements": ["eufylife-ble-client==0.1.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46aaccda8f3..aa4b62dac91 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -767,7 +767,7 @@ esphome-dashboard-api==1.2.3 eternalegypt==0.0.16 # homeassistant.components.eufylife_ble -eufylife-ble-client==0.1.7 +eufylife-ble-client==0.1.8 # homeassistant.components.keyboard_remote # evdev==1.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0657e650bed..0e3fce9a0bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -617,7 +617,7 @@ epson-projector==0.5.1 esphome-dashboard-api==1.2.3 # homeassistant.components.eufylife_ble -eufylife-ble-client==0.1.7 +eufylife-ble-client==0.1.8 # homeassistant.components.faa_delays faadelays==2023.9.1 From e5d5440385718e9d965881a26adddb886f6d18c1 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 9 Oct 2023 23:35:29 -0400 Subject: [PATCH 325/968] Fix Slack type error for file upload (#101720) Fix regression --- homeassistant/components/slack/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index deba0796750..aae2846503d 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -166,7 +166,7 @@ class SlackNotificationService(BaseNotificationService): filename=filename, initial_comment=message, title=title or filename, - thread_ts=thread_ts, + thread_ts=thread_ts or "", ) except (SlackApiError, ClientError) as err: _LOGGER.error("Error while uploading file-based message: %r", err) From 1944b2952ca0ebb44e35b0311324887f2be85820 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 10 Oct 2023 05:50:12 +0200 Subject: [PATCH 326/968] Replace object select in service calls with more UI-friendly selectors (#101722) --- homeassistant/components/flux_led/services.yaml | 4 ++-- homeassistant/components/group/services.yaml | 9 ++++++--- homeassistant/components/light/services.yaml | 2 +- homeassistant/components/mqtt/services.yaml | 2 +- homeassistant/components/scene/services.yaml | 3 ++- homeassistant/components/yeelight/services.yaml | 2 +- 6 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/flux_led/services.yaml b/homeassistant/components/flux_led/services.yaml index 73f479825da..8311be5b9a8 100644 --- a/homeassistant/components/flux_led/services.yaml +++ b/homeassistant/components/flux_led/services.yaml @@ -113,9 +113,9 @@ set_music_mode: example: "[255, 100, 100]" required: false selector: - object: + color_rgb: background_color: example: "[255, 100, 100]" required: false selector: - object: + color_rgb: diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index e5ac921cc77..ceed4bb7b42 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -18,15 +18,18 @@ set: entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true add_entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true remove_entities: example: domain.entity_id1, domain.entity_id2 selector: - object: + entity: + multiple: true all: selector: boolean: diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 1ba204e5eda..433da53a570 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -422,7 +422,7 @@ toggle: advanced: true example: "[255, 100, 100]" selector: - object: + color_rgb: color_name: filter: attribute: diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml index 4960cf9fb82..5102d481143 100644 --- a/homeassistant/components/mqtt/services.yaml +++ b/homeassistant/components/mqtt/services.yaml @@ -15,7 +15,7 @@ publish: advanced: true example: "{{ states('sensor.temperature') }}" selector: - object: + template: qos: advanced: true default: 0 diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index acd98b10255..b9e12bcc8d7 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -51,4 +51,5 @@ create: - light.ceiling - light.kitchen selector: - object: + entity: + multiple: true diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml index ccfd46ef680..825835dbcc7 100644 --- a/homeassistant/components/yeelight/services.yaml +++ b/homeassistant/components/yeelight/services.yaml @@ -25,7 +25,7 @@ set_color_scene: rgb_color: example: "[255, 100, 100]" selector: - object: + color_rgb: brightness: selector: number: From deffa501425bf473829e70fa4d76faf2b8b970c8 Mon Sep 17 00:00:00 2001 From: rappenze Date: Tue, 10 Oct 2023 06:07:29 +0200 Subject: [PATCH 327/968] Address late review from add fibaro event platform (#101718) --- homeassistant/components/fibaro/__init__.py | 8 ++++---- homeassistant/components/fibaro/event.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 0272c620b99..29fc2c5b774 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -194,8 +194,8 @@ class FibaroController: def register(self, device_id: int, callback: Any) -> None: """Register device with a callback for updates.""" - self._callbacks.setdefault(device_id, []) - self._callbacks[device_id].append(callback) + device_callbacks = self._callbacks.setdefault(device_id, []) + device_callbacks.append(callback) def register_event( self, device_id: int, callback: Callable[[FibaroEvent], None] @@ -204,8 +204,8 @@ class FibaroController: The callback receives one parameter with the event. """ - self._event_callbacks.setdefault(device_id, []) - self._event_callbacks[device_id].append(callback) + device_callbacks = self._event_callbacks.setdefault(device_id, []) + device_callbacks.append(callback) def get_children(self, device_id: int) -> list[DeviceModel]: """Get a list of child devices.""" diff --git a/homeassistant/components/fibaro/event.py b/homeassistant/components/fibaro/event.py index 33e4161087c..020a478db95 100644 --- a/homeassistant/components/fibaro/event.py +++ b/homeassistant/components/fibaro/event.py @@ -11,7 +11,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FibaroController, FibaroDevice @@ -41,16 +41,17 @@ class FibaroEventEntity(FibaroDevice, EventEntity): def __init__(self, fibaro_device: DeviceModel, scene_event: SceneEvent) -> None: """Initialize the Fibaro device.""" super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format( - f"{self.ha_id}_button_{scene_event.key_id}" - ) - self._button = scene_event.key_id + key_id = scene_event.key_id - self._attr_name = f"{fibaro_device.friendly_name} Button {scene_event.key_id}" + self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_button_{key_id}") + + self._button = key_id + + self._attr_name = f"{fibaro_device.friendly_name} Button {key_id}" self._attr_device_class = EventDeviceClass.BUTTON self._attr_event_types = scene_event.key_event_types - self._attr_unique_id = f"{fibaro_device.unique_id_str}.{scene_event.key_id}" + self._attr_unique_id = f"{fibaro_device.unique_id_str}.{key_id}" async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -61,7 +62,6 @@ class FibaroEventEntity(FibaroDevice, EventEntity): self.fibaro_device.fibaro_id, self._event_callback ) - @callback def _event_callback(self, event: FibaroEvent) -> None: if event.key_id == self._button: self._trigger_event(event.key_event_type) From c6a3fa30f09dac20c42c4348f5dd770bbe4a8f0b Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 10 Oct 2023 08:42:35 +0200 Subject: [PATCH 328/968] Add support for Minecraft Server Bedrock Edition (#100925) --- .coveragerc | 1 + .../components/minecraft_server/__init__.py | 35 +++-- .../components/minecraft_server/api.py | 134 ++++++++++++++++++ .../minecraft_server/binary_sensor.py | 7 +- .../minecraft_server/config_flow.py | 64 ++++----- .../minecraft_server/coordinator.py | 57 ++------ .../components/minecraft_server/entity.py | 11 +- .../components/minecraft_server/sensor.py | 75 +++++++++- .../components/minecraft_server/strings.json | 11 +- tests/components/minecraft_server/const.py | 40 ++++++ .../minecraft_server/test_config_flow.py | 119 +++++++++++++--- .../components/minecraft_server/test_init.py | 37 ++--- 12 files changed, 447 insertions(+), 144 deletions(-) create mode 100644 homeassistant/components/minecraft_server/api.py diff --git a/.coveragerc b/.coveragerc index 478454c18e8..a13959d0185 100644 --- a/.coveragerc +++ b/.coveragerc @@ -745,6 +745,7 @@ omit = homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.py + homeassistant/components/minecraft_server/api.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/coordinator.py homeassistant/components/minecraft_server/entity.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 7f2b08c96ef..53324e6d5a4 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -4,14 +4,21 @@ from __future__ import annotations import logging from typing import Any -from mcstatus import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_PORT, Platform +from homeassistant.const import ( + CONF_ADDRESS, + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_TYPE, + Platform, +) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryError import homeassistant.helpers.device_registry as dr import homeassistant.helpers.entity_registry as er +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .coordinator import MinecraftServerCoordinator @@ -23,8 +30,20 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" + # Check and create API instance. + try: + api = await hass.async_add_executor_job( + MinecraftServer, + entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION), + entry.data[CONF_ADDRESS], + ) + except MinecraftServerAddressError as error: + raise ConfigEntryError( + f"Server address in configuration entry is invalid (error: {error})" + ) from error + # Create coordinator instance. - coordinator = MinecraftServerCoordinator(hass, entry) + coordinator = MinecraftServerCoordinator(hass, entry.data[CONF_NAME], api) await coordinator.async_config_entry_first_refresh() # Store coordinator instance. @@ -85,9 +104,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate config entry. try: address = config_data[CONF_HOST] - JavaServer.lookup(address) + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) host_only_lookup_success = True - except ValueError as error: + except MinecraftServerAddressError as error: host_only_lookup_success = False _LOGGER.debug( "Hostname (without port) cannot be parsed (error: %s), trying again with port", @@ -97,8 +116,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> if not host_only_lookup_success: try: address = f"{config_data[CONF_HOST]}:{config_data[CONF_PORT]}" - JavaServer.lookup(address) - except ValueError as error: + MinecraftServer(MinecraftServerType.JAVA_EDITION, address) + except MinecraftServerAddressError as error: _LOGGER.exception( "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", error, diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py new file mode 100644 index 00000000000..d0bd679def8 --- /dev/null +++ b/homeassistant/components/minecraft_server/api.py @@ -0,0 +1,134 @@ +"""API for the Minecraft Server integration.""" + + +from dataclasses import dataclass +from enum import StrEnum +import logging + +from dns.resolver import LifetimeTimeout +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class MinecraftServerData: + """Representation of Minecraft Server data.""" + + # Common data + latency: float + motd: str + players_max: int + players_online: int + protocol_version: int + version: str + + # Data available only in 'Java Edition' + players_list: list[str] | None = None + + # Data available only in 'Bedrock Edition' + edition: str | None = None + game_mode: str | None = None + map_name: str | None = None + + +class MinecraftServerType(StrEnum): + """Enumeration of Minecraft Server types.""" + + BEDROCK_EDITION = "Bedrock Edition" + JAVA_EDITION = "Java Edition" + + +class MinecraftServerAddressError(Exception): + """Raised when the input address is invalid.""" + + +class MinecraftServerConnectionError(Exception): + """Raised when no data can be fechted from the server.""" + + +class MinecraftServer: + """Minecraft Server wrapper class for 3rd party library mcstatus.""" + + _server: BedrockServer | JavaServer + + def __init__(self, server_type: MinecraftServerType, address: str) -> None: + """Initialize server instance.""" + try: + if server_type == MinecraftServerType.JAVA_EDITION: + self._server = JavaServer.lookup(address) + else: + self._server = BedrockServer.lookup(address) + except (ValueError, LifetimeTimeout) as error: + raise MinecraftServerAddressError( + f"{server_type} server address '{address}' is invalid (error: {error})" + ) from error + + _LOGGER.debug( + "%s server instance created with address '%s'", server_type, address + ) + + async def async_is_online(self) -> bool: + """Check if the server is online, supporting both Java and Bedrock Edition servers.""" + try: + await self.async_get_data() + except MinecraftServerConnectionError: + return False + + return True + + async def async_get_data(self) -> MinecraftServerData: + """Get updated data from the server, supporting both Java and Bedrock Edition servers.""" + status_response: BedrockStatusResponse | JavaStatusResponse + + try: + status_response = await self._server.async_status() + except OSError as error: + raise MinecraftServerConnectionError( + f"Fetching data from the server failed (error: {error})" + ) from error + + if isinstance(status_response, JavaStatusResponse): + data = self._extract_java_data(status_response) + else: + data = self._extract_bedrock_data(status_response) + + return data + + def _extract_java_data( + self, status_response: JavaStatusResponse + ) -> MinecraftServerData: + """Extract Java Edition server data out of status response.""" + players_list = [] + + if players := status_response.players.sample: + for player in players: + players_list.append(player.name) + players_list.sort() + + return MinecraftServerData( + latency=status_response.latency, + motd=status_response.motd.to_plain(), + players_max=status_response.players.max, + players_online=status_response.players.online, + protocol_version=status_response.version.protocol, + version=status_response.version.name, + players_list=players_list, + ) + + def _extract_bedrock_data( + self, status_response: BedrockStatusResponse + ) -> MinecraftServerData: + """Extract Bedrock Edition server data out of status response.""" + return MinecraftServerData( + latency=status_response.latency, + motd=status_response.motd.to_plain(), + players_max=status_response.players.max, + players_online=status_response.players.online, + protocol_version=status_response.version.protocol, + version=status_response.version.name, + edition=status_response.version.brand, + game_mode=status_response.gamemode, + map_name=status_response.map_name, + ) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index e89fce2d7d5..520d7342b35 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -45,7 +45,7 @@ async def async_setup_entry( # Add binary sensor entities. async_add_entities( [ - MinecraftServerBinarySensorEntity(coordinator, description) + MinecraftServerBinarySensorEntity(coordinator, description, config_entry) for description in BINARY_SENSOR_DESCRIPTIONS ] ) @@ -60,11 +60,12 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit self, coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_is_on = False @property diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index 527dfa1ed04..f064a4ac1ef 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,13 +1,13 @@ """Config flow for Minecraft Server integration.""" import logging -from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.data_entry_flow import FlowResult +from .api import MinecraftServer, MinecraftServerAddressError, MinecraftServerType from .const import DEFAULT_NAME, DOMAIN DEFAULT_ADDRESS = "localhost:25565" @@ -27,10 +27,28 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): if user_input: address = user_input[CONF_ADDRESS] - if await self._async_is_server_online(address): - # No error was detected, create configuration entry. - config_data = {CONF_NAME: user_input[CONF_NAME], CONF_ADDRESS: address} - return self.async_create_entry(title=address, data=config_data) + # Prepare config entry data. + config_data = { + CONF_NAME: user_input[CONF_NAME], + CONF_ADDRESS: address, + } + + # Some Bedrock Edition servers mimic a Java Edition server, therefore check for a Bedrock Edition server first. + for server_type in MinecraftServerType: + try: + api = await self.hass.async_add_executor_job( + MinecraftServer, server_type, address + ) + except MinecraftServerAddressError: + pass + else: + if await api.async_is_online(): + config_data[CONF_TYPE] = server_type + return self.async_create_entry(title=address, data=config_data) + + _LOGGER.debug( + "Connection check to %s server '%s' failed", server_type, address + ) # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" @@ -59,37 +77,3 @@ class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): ), errors=errors, ) - - async def _async_is_server_online(self, address: str) -> bool: - """Check server connection using a 'status' request and return result.""" - - # Parse and check server address. - try: - server = await JavaServer.async_lookup(address) - except ValueError as error: - _LOGGER.debug( - ( - "Error occurred while parsing server address '%s' -" - " ValueError: %s" - ), - address, - error, - ) - return False - - # Send a status request to the server. - try: - await server.async_status() - return True - except OSError as error: - _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - server.address.host, - server.address.port, - error, - ) - - return False diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 9b5ab1fbb43..f7a60318c64 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -1,77 +1,36 @@ """The Minecraft Server integration.""" from __future__ import annotations -from dataclasses import dataclass from datetime import timedelta import logging -from mcstatus.server import JavaServer - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS, CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .api import MinecraftServer, MinecraftServerConnectionError, MinecraftServerData + SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) -@dataclass -class MinecraftServerData: - """Representation of Minecraft Server data.""" - - latency: float - motd: str - players_max: int - players_online: int - players_list: list[str] - protocol_version: int - version: str - - class MinecraftServerCoordinator(DataUpdateCoordinator[MinecraftServerData]): """Minecraft Server data update coordinator.""" - def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, name: str, api: MinecraftServer) -> None: """Initialize coordinator instance.""" - config_data = config_entry.data - self.unique_id = config_entry.entry_id + self._api = api super().__init__( hass=hass, - name=config_data[CONF_NAME], + name=name, logger=_LOGGER, update_interval=SCAN_INTERVAL, ) - try: - self._server = JavaServer.lookup(config_data[CONF_ADDRESS]) - except ValueError as error: - raise HomeAssistantError( - f"Address in configuration entry cannot be parsed (error: {error}), please remove this device and add it again" - ) from error - async def _async_update_data(self) -> MinecraftServerData: - """Get server data from 3rd party library and update properties.""" + """Get updated data from the server.""" try: - status_response = await self._server.async_status() - except OSError as error: + return await self._api.async_get_data() + except MinecraftServerConnectionError as error: raise UpdateFailed(error) from error - - players_list = [] - if players := status_response.players.sample: - for player in players: - players_list.append(player.name) - players_list.sort() - - return MinecraftServerData( - version=status_response.version.name, - protocol_version=status_response.version.protocol, - players_online=status_response.players.online, - players_max=status_response.players.max, - players_list=players_list, - latency=status_response.latency, - motd=status_response.motd.to_plain(), - ) diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 9bac71e0000..9a94fb4e168 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,8 +1,11 @@ """Base entity for the Minecraft Server integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TYPE from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity +from .api import MinecraftServerType from .const import DOMAIN from .coordinator import MinecraftServerCoordinator @@ -17,13 +20,15 @@ class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): def __init__( self, coordinator: MinecraftServerCoordinator, + config_entry: ConfigEntry, ) -> None: """Initialize base entity.""" super().__init__(coordinator) + self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, coordinator.unique_id)}, + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({coordinator.data.version})", + model=f"Minecraft Server ({config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION)})", name=coordinator.name, - sw_version=str(coordinator.data.protocol_version), + sw_version=f"{coordinator.data.version} ({coordinator.data.protocol_version})", ) diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index efe534e0f92..e63649c9239 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,17 +7,21 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfTime +from homeassistant.const import CONF_TYPE, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from .api import MinecraftServerData, MinecraftServerType from .const import DOMAIN, KEY_LATENCY, KEY_MOTD -from .coordinator import MinecraftServerCoordinator, MinecraftServerData +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity ATTR_PLAYERS_LIST = "players_list" +ICON_EDITION = "mdi:minecraft" +ICON_GAME_MODE = "mdi:cog" +ICON_MAP_NAME = "mdi:map" ICON_LATENCY = "mdi:signal" ICON_PLAYERS_MAX = "mdi:account-multiple" ICON_PLAYERS_ONLINE = "mdi:account-multiple" @@ -25,6 +29,9 @@ ICON_PROTOCOL_VERSION = "mdi:numeric" ICON_VERSION = "mdi:numeric" ICON_MOTD = "mdi:minecraft" +KEY_EDITION = "edition" +KEY_GAME_MODE = "game_mode" +KEY_MAP_NAME = "map_name" KEY_PLAYERS_MAX = "players_max" KEY_PLAYERS_ONLINE = "players_online" KEY_PROTOCOL_VERSION = "protocol_version" @@ -40,6 +47,7 @@ class MinecraftServerEntityDescriptionMixin: value_fn: Callable[[MinecraftServerData], StateType] attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + supported_server_types: list[MinecraftServerType] @dataclass @@ -69,6 +77,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_VERSION, value_fn=lambda data: data.version, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PROTOCOL_VERSION, @@ -76,6 +88,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PROTOCOL_VERSION, value_fn=lambda data: data.protocol_version, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_MAX, @@ -84,6 +100,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_MAX, value_fn=lambda data: data.players_max, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_LATENCY, @@ -93,6 +113,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_LATENCY, value_fn=lambda data: data.latency, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_MOTD, @@ -100,6 +124,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_MOTD, value_fn=lambda data: data.motd, attributes_fn=None, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_ONLINE, @@ -108,6 +136,40 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_ONLINE, value_fn=lambda data: data.players_online, attributes_fn=get_extra_state_attributes_players_list, + supported_server_types=[ + MinecraftServerType.JAVA_EDITION, + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_EDITION, + translation_key=KEY_EDITION, + icon=ICON_EDITION, + value_fn=lambda data: data.edition, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_GAME_MODE, + translation_key=KEY_GAME_MODE, + icon=ICON_GAME_MODE, + value_fn=lambda data: data.game_mode, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], + ), + MinecraftServerSensorEntityDescription( + key=KEY_MAP_NAME, + translation_key=KEY_MAP_NAME, + icon=ICON_MAP_NAME, + value_fn=lambda data: data.map_name, + attributes_fn=None, + supported_server_types=[ + MinecraftServerType.BEDROCK_EDITION, + ], ), ] @@ -123,8 +185,10 @@ async def async_setup_entry( # Add sensor entities. async_add_entities( [ - MinecraftServerSensorEntity(coordinator, description) + MinecraftServerSensorEntity(coordinator, description, config_entry) for description in SENSOR_DESCRIPTIONS + if config_entry.data.get(CONF_TYPE, MinecraftServerType.JAVA_EDITION) + in description.supported_server_types ] ) @@ -138,11 +202,12 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): self, coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, + config_entry: ConfigEntry, ) -> None: """Initialize sensor base entity.""" - super().__init__(coordinator) + super().__init__(coordinator, config_entry) self.entity_description = description - self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._update_properties() @callback diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index c5fe5b81d81..622a45a5aeb 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -11,7 +11,7 @@ } }, "error": { - "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. Also ensure that you are running at least version 1.7 of Minecraft Java Edition on your server." + "cannot_connect": "Failed to connect to server. Please check the address and try again. If a port was provided, it must be within a valid range. If you are running a Minecraft Java Edition server, ensure that it is at least version 1.7." } }, "entity": { @@ -38,6 +38,15 @@ }, "motd": { "name": "World message" + }, + "game_mode": { + "name": "Game mode" + }, + "map_name": { + "name": "Map name" + }, + "edition": { + "name": "Edition" } } } diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index c7eb0e4b096..c579461611e 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -1,11 +1,16 @@ """Constants for Minecraft Server integration tests.""" from mcstatus.motd import Motd from mcstatus.status_response import ( + BedrockStatusPlayers, + BedrockStatusResponse, + BedrockStatusVersion, JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, ) +from homeassistant.components.minecraft_server.api import MinecraftServerData + TEST_HOST = "mc.dummyserver.com" TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" @@ -32,3 +37,38 @@ TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( icon=None, latency=5, ) + +TEST_JAVA_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=["Player 1", "Player 2", "Player 3"], + edition=None, + game_mode=None, + map_name=None, +) + +TEST_BEDROCK_STATUS_RESPONSE = BedrockStatusResponse( + players=BedrockStatusPlayers(online=3, max=10), + version=BedrockStatusVersion(brand="MCPE", name="Dummy Version", protocol=123), + motd=Motd.parse("Dummy Description", bedrock=True), + latency=5, + gamemode="Dummy Game Mode", + map_name="Dummy Map Name", +) + +TEST_BEDROCK_DATA = MinecraftServerData( + latency=5, + motd="Dummy MOTD", + players_max=10, + players_online=3, + protocol_version=123, + version="Dummy Version", + players_list=None, + edition="Dummy Edition", + game_mode="Dummy Game Mode", + map_name="Dummy Map Name", +) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 88afa6576d5..cca6d5d21ac 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -2,15 +2,17 @@ from unittest.mock import patch -from mcstatus import JavaServer - +from homeassistant.components.minecraft_server.api import ( + MinecraftServerAddressError, + MinecraftServerType, +) from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import TEST_ADDRESS USER_INPUT = { CONF_NAME: DEFAULT_NAME, @@ -28,11 +30,12 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_lookup_failed(hass: HomeAssistant) -> None: +async def test_address_validation_failed(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - side_effect=ValueError, + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], + return_value=None, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -42,12 +45,16 @@ async def test_lookup_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_failed(hass: HomeAssistant) -> None: - """Test error in case of a failed connection.""" +async def test_java_connection_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Java Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), - ), patch("mcstatus.server.JavaServer.async_status", side_effect=OSError): + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, None], + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=False, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT ) @@ -56,14 +63,37 @@ async def test_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_connection_succeeded(hass: HomeAssistant) -> None: - """Test config entry in case of a successful connection with a host name.""" +async def test_bedrock_connection_failed(hass: HomeAssistant) -> None: + """Test error in case of a failed connection to a Bedrock Edition server.""" with patch( - "mcstatus.server.JavaServer.async_lookup", - return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[None, MinecraftServerAddressError], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_java_connection_succeeded(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Java Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[ + MinecraftServerAddressError, # async_step_user (Bedrock Edition) + None, # async_step_user (Java Edition) + None, # async_setup_entry + ], + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -73,3 +103,56 @@ async def test_connection_succeeded(hass: HomeAssistant) -> None: assert result["title"] == USER_INPUT[CONF_ADDRESS] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION + + +async def test_bedrock_connection_succeeded(hass: HomeAssistant) -> None: + """Test config entry in case of a successful connection to a Bedrock Edition server.""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=None, + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == USER_INPUT[CONF_ADDRESS] + assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION + + +async def test_recovery(hass: HomeAssistant) -> None: + """Test config flow recovery (successful connection after a failed connection).""" + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + with patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + side_effect=None, + return_value=None, + ), patch( + "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"], user_input=USER_INPUT + ) + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == USER_INPUT[CONF_ADDRESS] + assert result2["data"][CONF_NAME] == USER_INPUT[CONF_NAME] + assert result2["data"][CONF_ADDRESS] == TEST_ADDRESS + assert result2["data"][CONF_TYPE] == MinecraftServerType.BEDROCK_EDITION diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 1e3679fb1e3..cc9730ef3df 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -1,16 +1,15 @@ """Tests for the Minecraft Server integration.""" from unittest.mock import patch -from mcstatus import JavaServer - from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.api import MinecraftServerAddressError from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_STATUS_RESPONSE, TEST_PORT +from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_DATA, TEST_PORT from tests.common import MockConfigEntry @@ -122,15 +121,16 @@ async def test_entry_migration(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - ValueError, - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), + MinecraftServerAddressError, # async_migrate_entry + None, # async_migrate_entry + None, # async_setup_entry ], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", + return_value=TEST_JAVA_DATA, ): assert await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -142,6 +142,7 @@ async def test_entry_migration(hass: HomeAssistant) -> None: CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } + assert config_entry.version == 3 # Test migrated device entry. @@ -174,14 +175,15 @@ async def test_entry_migration_host_only(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - JavaServer(host=TEST_HOST, port=TEST_PORT), - JavaServer(host=TEST_HOST, port=TEST_PORT), + None, # async_migrate_entry + None, # async_setup_entry ], + return_value=None, ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=TEST_JAVA_STATUS_RESPONSE, + "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", + return_value=TEST_JAVA_DATA, ): assert await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() @@ -205,11 +207,12 @@ async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: # Trigger migration. with patch( - "mcstatus.server.JavaServer.lookup", + "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", side_effect=[ - ValueError, - ValueError, + MinecraftServerAddressError, # async_migrate_entry + MinecraftServerAddressError, # async_migrate_entry ], + return_value=None, ): assert not await hass.config_entries.async_setup(config_entry_id) await hass.async_block_till_done() From 9afdc2281804d6f8e81ac6fbbf6ed89996c7e798 Mon Sep 17 00:00:00 2001 From: Sam Reed Date: Tue, 10 Oct 2023 07:52:25 +0100 Subject: [PATCH 329/968] supla: Change casing of integration name to upstream SUPLA (#101723) --- homeassistant/components/supla/cover.py | 8 ++++---- homeassistant/components/supla/entity.py | 6 +++--- homeassistant/components/supla/manifest.json | 2 +- homeassistant/components/supla/switch.py | 6 +++--- homeassistant/generated/integrations.json | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index cc3a5a4ed0c..7f2857395b8 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -1,4 +1,4 @@ -"""Support for Supla cover - curtains, rollershutters, entry gate etc.""" +"""Support for SUPLA covers - curtains, rollershutters, entry gate etc.""" from __future__ import annotations import logging @@ -26,7 +26,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Supla covers.""" + """Set up the SUPLA covers.""" if discovery_info is None: return @@ -59,7 +59,7 @@ async def async_setup_platform( class SuplaCoverEntity(SuplaEntity, CoverEntity): - """Representation of a Supla Cover.""" + """Representation of a SUPLA Cover.""" @property def current_cover_position(self) -> int | None: @@ -93,7 +93,7 @@ class SuplaCoverEntity(SuplaEntity, CoverEntity): class SuplaDoorEntity(SuplaEntity, CoverEntity): - """Representation of a Supla door.""" + """Representation of a SUPLA door.""" _attr_device_class = CoverDeviceClass.GARAGE diff --git a/homeassistant/components/supla/entity.py b/homeassistant/components/supla/entity.py index ae0a627b538..244048973fa 100644 --- a/homeassistant/components/supla/entity.py +++ b/homeassistant/components/supla/entity.py @@ -1,4 +1,4 @@ -"""Base class for Supla channels.""" +"""Base class for SUPLA channels.""" from __future__ import annotations import logging @@ -9,7 +9,7 @@ _LOGGER = logging.getLogger(__name__) class SuplaEntity(CoordinatorEntity): - """Base class of a Supla Channel (an equivalent of HA's Entity).""" + """Base class of a SUPLA Channel (an equivalent of HA's Entity).""" def __init__(self, config, server, coordinator): """Init from config, hookup[ server and coordinator.""" @@ -49,7 +49,7 @@ class SuplaEntity(CoordinatorEntity): """Run server action. Actions are currently hardcoded in components. - Supla's API enables autodiscovery + SUPLA's API enables autodiscovery """ _LOGGER.debug( "Executing action %s on channel %d, params: %s", diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json index 6611c0d795b..6927c92c6e1 100644 --- a/homeassistant/components/supla/manifest.json +++ b/homeassistant/components/supla/manifest.json @@ -1,6 +1,6 @@ { "domain": "supla", - "name": "Supla", + "name": "SUPLA", "codeowners": ["@mwegrzynek"], "documentation": "https://www.home-assistant.io/integrations/supla", "iot_class": "cloud_polling", diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index b270f4300e1..d904455a3fe 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -1,4 +1,4 @@ -"""Support for Supla switch.""" +"""Support for SUPLA switch.""" from __future__ import annotations import logging @@ -22,7 +22,7 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Supla switches.""" + """Set up the SUPLA switches.""" if discovery_info is None: return @@ -44,7 +44,7 @@ async def async_setup_platform( class SuplaSwitchEntity(SuplaEntity, SwitchEntity): - """Representation of a Supla Switch.""" + """Representation of a SUPLA Switch.""" async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b84db6bd1ac..92e4286404f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5503,7 +5503,7 @@ "iot_class": "local_polling" }, "supla": { - "name": "Supla", + "name": "SUPLA", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_polling" From 6d632bd1ab39296c3bd35e4cf89a81340bfba0aa Mon Sep 17 00:00:00 2001 From: Betacart Date: Tue, 10 Oct 2023 09:50:17 +0200 Subject: [PATCH 330/968] Fix typo in Ombi translation strings (#101747) Update strings.json Fixed typo ("Sumbit" -> "Submit") --- homeassistant/components/ombi/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ombi/strings.json b/homeassistant/components/ombi/strings.json index 2cf18248ab8..764eb5ff1b5 100644 --- a/homeassistant/components/ombi/strings.json +++ b/homeassistant/components/ombi/strings.json @@ -1,7 +1,7 @@ { "services": { "submit_movie_request": { - "name": "Sumbit movie request", + "name": "Submit movie request", "description": "Searches for a movie and requests the first result.", "fields": { "name": { From 00abf496373f848fbc86cd624074cf4e6764c10b Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Tue, 10 Oct 2023 01:15:24 -0700 Subject: [PATCH 331/968] Bump screenlogicpy to 0.9.2 (#101746) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 4d9bbacf3a8..a57ad0026e6 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.1"] + "requirements": ["screenlogicpy==0.9.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index aa4b62dac91..e5eeaaa48f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2375,7 +2375,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.1 +screenlogicpy==0.9.2 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e3fce9a0bc..6f08b72228d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1762,7 +1762,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.1 +screenlogicpy==0.9.2 # homeassistant.components.backup securetar==2023.3.0 From f78199df9f4579b0375cbdee13359ab48e8f7eea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 10 Oct 2023 10:18:52 +0200 Subject: [PATCH 332/968] Fix Airzone climate double setpoint (#101744) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 10 +++++----- tests/components/airzone/test_climate.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 74a564fa2de..c3ba74236bd 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -217,8 +217,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): if ATTR_TEMPERATURE in kwargs: params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE] if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: - params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] - params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH] + params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH] + params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] await self._async_update_hvac_params(params) @callback @@ -248,8 +248,8 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: self._attr_target_temperature_high = self.get_airzone_value( - AZD_HEAT_TEMP_SET - ) - self._attr_target_temperature_low = self.get_airzone_value( AZD_COOL_TEMP_SET ) + self._attr_target_temperature_low = self.get_airzone_value( + AZD_HEAT_TEMP_SET + ) diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index 591584da10b..94bea0a5e07 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -615,5 +615,5 @@ async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.dkn_plus") - assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 - assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 20.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 25.0 From 915f5bf84e108bba1d7041fbb057e7cc6d28cbd3 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Oct 2023 12:43:12 +0200 Subject: [PATCH 333/968] Reset the threading.local _hass object every time (#101728) * Reset the threading.local _hass object every time * Remove reset from hass fixture --- tests/conftest.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c04b90d349e..09ad70bfcf1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -376,6 +376,13 @@ def verify_cleanup( ) +@pytest.fixture(autouse=True) +def reset_hass_threading_local_object() -> Generator[None, None, None]: + """Reset the _Hass threading.local object for every test case.""" + yield + ha._hass.__dict__.clear() + + @pytest.fixture(autouse=True) def bcrypt_cost() -> Generator[None, None, None]: """Run with reduced rounds during tests, to speed up uses.""" @@ -559,9 +566,6 @@ async def hass( # Restore timezone, it is set when creating the hass object dt_util.DEFAULT_TIME_ZONE = orig_tz - # Reset the _Hass threading.local object - ha._hass.__dict__.clear() - for ex in exceptions: if ( request.module.__name__, From 31bd500222ada996243ed35760de10d05f5e168e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 10 Oct 2023 14:02:32 +0200 Subject: [PATCH 334/968] Make get_channel available as generic helper (#101721) * Make get_channel available as generic helper * Follow up comment --- homeassistant/components/sentry/__init__.py | 15 ++----------- homeassistant/core.py | 25 ++++++++++++++++++++- tests/components/sentry/test_init.py | 16 +------------ tests/test_core.py | 16 +++++++++++++ 4 files changed, 43 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 8815986d368..5e4fb80688d 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, __version__ as current_version, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, get_release_channel from homeassistant.helpers import config_validation as cv, entity_platform, instance_id from homeassistant.helpers.event import async_call_later from homeassistant.helpers.system_info import async_get_system_info @@ -67,7 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Additional/extra data collection - channel = get_channel(current_version) + channel = get_release_channel() huuid = await instance_id.async_get(hass) system_info = await async_get_system_info(hass) custom_components = await async_get_custom_components(hass) @@ -110,17 +110,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def get_channel(version: str) -> str: - """Find channel based on version number.""" - if "dev0" in version: - return "dev" - if "dev" in version: - return "nightly" - if "b" in version: - return "beta" - return "stable" - - def process_before_send( hass: HomeAssistant, options: MappingProxyType[str, Any], diff --git a/homeassistant/core.py b/homeassistant/core.py index a50d43c1344..2cc79e5bbb4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -28,7 +28,17 @@ import re import threading import time from time import monotonic -from typing import TYPE_CHECKING, Any, Generic, ParamSpec, Self, TypeVar, cast, overload +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Literal, + ParamSpec, + Self, + TypeVar, + cast, + overload, +) from urllib.parse import urlparse import voluptuous as vol @@ -222,6 +232,19 @@ def async_get_hass() -> HomeAssistant: return _hass.hass +@callback +def get_release_channel() -> Literal["beta", "dev", "nightly", "stable"]: + """Find release channel based on version number.""" + version = __version__ + if "dev0" in version: + return "dev" + if "dev" in version: + return "nightly" + if "b" in version: + return "beta" + return "stable" + + @enum.unique class HassJobType(enum.Enum): """Represent a job type.""" diff --git a/tests/components/sentry/test_init.py b/tests/components/sentry/test_init.py index 25b77922878..73f6a7cfd09 100644 --- a/tests/components/sentry/test_init.py +++ b/tests/components/sentry/test_init.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components.sentry import get_channel, process_before_send +from homeassistant.components.sentry import process_before_send from homeassistant.components.sentry.const import ( CONF_DSN, CONF_ENVIRONMENT, @@ -103,20 +103,6 @@ async def test_setup_entry_with_tracing(hass: HomeAssistant) -> None: assert call_args["traces_sample_rate"] == 0.5 -@pytest.mark.parametrize( - ("version", "channel"), - [ - ("0.115.0.dev20200815", "nightly"), - ("0.115.0", "stable"), - ("0.115.0b4", "beta"), - ("0.115.0dev0", "dev"), - ], -) -async def test_get_channel(version: str, channel: str) -> None: - """Test if channel detection works from Home Assistant version number.""" - assert get_channel(version) == channel - - async def test_process_before_send(hass: HomeAssistant) -> None: """Test regular use of the Sentry process before sending function.""" hass.config.components.add("puppies") diff --git a/tests/test_core.py b/tests/test_core.py index 7cafadb638c..ed6823d2bd1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -43,6 +43,7 @@ from homeassistant.core import ( State, SupportsResponse, callback, + get_release_channel, ) from homeassistant.exceptions import ( HomeAssistantError, @@ -2481,3 +2482,18 @@ async def test_validate_state(hass: HomeAssistant) -> None: assert ha.validate_state("test") == "test" with pytest.raises(InvalidStateError): ha.validate_state("t" * 256) + + +@pytest.mark.parametrize( + ("version", "release_channel"), + [ + ("0.115.0.dev20200815", "nightly"), + ("0.115.0", "stable"), + ("0.115.0b4", "beta"), + ("0.115.0dev0", "dev"), + ], +) +async def test_get_release_channel(version: str, release_channel: str) -> None: + """Test if release channel detection works from Home Assistant version number.""" + with patch("homeassistant.core.__version__", f"{version}"): + assert get_release_channel() == release_channel From ecdb0bb46a192312906d3705b9989e446d176845 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Oct 2023 16:49:36 +0200 Subject: [PATCH 335/968] Modernize metoffice weather (#99050) * Modernize metoffice weather * Exclude sensors from additional test --- .../components/metoffice/__init__.py | 6 +- homeassistant/components/metoffice/weather.py | 106 +- .../metoffice/snapshots/test_weather.ambr | 1929 +++++++++++++++++ tests/components/metoffice/test_weather.py | 330 ++- 4 files changed, 2308 insertions(+), 63 deletions(-) create mode 100644 tests/components/metoffice/snapshots/test_weather.ambr diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index 56bf5ee99ce..a658de9a024 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from .const import ( DEFAULT_SCAN_INTERVAL, @@ -105,7 +105,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: fetch_data, connection, site, MODE_DAILY ) - metoffice_hourly_coordinator = DataUpdateCoordinator( + metoffice_hourly_coordinator = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"MetOffice Hourly Coordinator for {site_name}", @@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=DEFAULT_SCAN_INTERVAL, ) - metoffice_daily_coordinator = DataUpdateCoordinator( + metoffice_daily_coordinator = TimestampDataUpdateCoordinator( hass, _LOGGER, name=f"MetOffice Daily Coordinator for {site_name}", diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 0b4672ddec8..b6e35168276 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,7 +1,7 @@ """Support for UK Met Office weather service.""" from __future__ import annotations -from typing import Any +from typing import Any, cast from datapoint.Timestep import Timestep @@ -11,17 +11,17 @@ from homeassistant.components.weather import ( ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_WIND_BEARING, + DOMAIN as WEATHER_DOMAIN, + CoordinatorWeatherEntity, Forecast, - WeatherEntity, + WeatherEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator from . import get_device_info from .const import ( @@ -41,15 +41,34 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Met Office weather sensor platform.""" + entity_registry = er.async_get(hass) hass_data = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - MetOfficeWeather(hass_data[METOFFICE_HOURLY_COORDINATOR], hass_data, True), - MetOfficeWeather(hass_data[METOFFICE_DAILY_COORDINATOR], hass_data, False), - ], - False, - ) + entities = [ + MetOfficeWeather( + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + False, + ) + ] + + # Add hourly entity to legacy config entries + if entity_registry.async_get_entity_id( + WEATHER_DOMAIN, + DOMAIN, + _calculate_unique_id(hass_data[METOFFICE_COORDINATES], True), + ): + entities.append( + MetOfficeWeather( + hass_data[METOFFICE_DAILY_COORDINATOR], + hass_data[METOFFICE_HOURLY_COORDINATOR], + hass_data, + True, + ) + ) + + async_add_entities(entities, False) def _build_forecast_data(timestep: Timestep) -> Forecast: @@ -67,8 +86,20 @@ def _build_forecast_data(timestep: Timestep) -> Forecast: return data +def _calculate_unique_id(coordinates: str, use_3hourly: bool) -> str: + """Calculate unique ID.""" + if use_3hourly: + return coordinates + return f"{coordinates}_{MODE_DAILY}" + + class MetOfficeWeather( - CoordinatorEntity[DataUpdateCoordinator[MetOfficeData]], WeatherEntity + CoordinatorWeatherEntity[ + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], + TimestampDataUpdateCoordinator[MetOfficeData], # Can be removed in Python 3.12 + ] ): """Implementation of a Met Office weather condition.""" @@ -78,23 +109,36 @@ class MetOfficeWeather( _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_pressure_unit = UnitOfPressure.HPA _attr_native_wind_speed_unit = UnitOfSpeed.MILES_PER_HOUR + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_DAILY + ) def __init__( self, - coordinator: DataUpdateCoordinator[MetOfficeData], + coordinator_daily: TimestampDataUpdateCoordinator[MetOfficeData], + coordinator_hourly: TimestampDataUpdateCoordinator[MetOfficeData], hass_data: dict[str, Any], use_3hourly: bool, ) -> None: """Initialise the platform with a data instance.""" - super().__init__(coordinator) + self._hourly = use_3hourly + if use_3hourly: + observation_coordinator = coordinator_hourly + else: + observation_coordinator = coordinator_daily + super().__init__( + observation_coordinator, + daily_coordinator=coordinator_daily, + hourly_coordinator=coordinator_hourly, + ) self._attr_device_info = get_device_info( coordinates=hass_data[METOFFICE_COORDINATES], name=hass_data[METOFFICE_NAME] ) self._attr_name = "3-Hourly" if use_3hourly else "Daily" - self._attr_unique_id = hass_data[METOFFICE_COORDINATES] - if not use_3hourly: - self._attr_unique_id = f"{self._attr_unique_id}_{MODE_DAILY}" + self._attr_unique_id = _calculate_unique_id( + hass_data[METOFFICE_COORDINATES], use_3hourly + ) @property def condition(self) -> str | None: @@ -155,3 +199,25 @@ class MetOfficeWeather( _build_forecast_data(timestep) for timestep in self.coordinator.data.forecast ] + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the twice daily forecast in native units.""" + coordinator = cast( + TimestampDataUpdateCoordinator[MetOfficeData], + self.forecast_coordinators["daily"], + ) + return [ + _build_forecast_data(timestep) for timestep in coordinator.data.forecast + ] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast in native units.""" + coordinator = cast( + TimestampDataUpdateCoordinator[MetOfficeData], + self.forecast_coordinators["hourly"], + ) + return [ + _build_forecast_data(timestep) for timestep in coordinator.data.forecast + ] diff --git a/tests/components/metoffice/snapshots/test_weather.ambr b/tests/components/metoffice/snapshots/test_weather.ambr new file mode 100644 index 00000000000..38df9f04ab2 --- /dev/null +++ b/tests/components/metoffice/snapshots/test_weather.ambr @@ -0,0 +1,1929 @@ +# serializer version: 1 +# name: test_forecast_service + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_service.3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]), + }) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly] + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].1 + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].2 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_3_hourly].3 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily] + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].1 + list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 13.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].2 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- +# name: test_forecast_subscription[weather.met_office_wavertree_daily].3 + list([ + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-25T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 19.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T18:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 17.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-25T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 14.0, + 'wind_bearing': 'NW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T00:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 13.0, + 'wind_bearing': 'WSW', + 'wind_speed': 3.22, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-26T03:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T09:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T12:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 12.0, + 'wind_bearing': 'WNW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T15:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 12.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T18:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 11.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-26T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T00:00:00+00:00', + 'precipitation_probability': 11, + 'temperature': 9.0, + 'wind_bearing': 'WNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T03:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 8.0, + 'wind_bearing': 'WNW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-27T06:00:00+00:00', + 'precipitation_probability': 14, + 'temperature': 8.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-27T12:00:00+00:00', + 'precipitation_probability': 4, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T15:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-27T18:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 10.0, + 'wind_bearing': 'NW', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-27T21:00:00+00:00', + 'precipitation_probability': 1, + 'temperature': 9.0, + 'wind_bearing': 'NW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T00:00:00+00:00', + 'precipitation_probability': 2, + 'temperature': 8.0, + 'wind_bearing': 'NNW', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'clear-night', + 'datetime': '2020-04-28T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 7.0, + 'wind_bearing': 'W', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2020-04-28T06:00:00+00:00', + 'precipitation_probability': 5, + 'temperature': 6.0, + 'wind_bearing': 'S', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-28T09:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T12:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'ENE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T15:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 12.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T18:00:00+00:00', + 'precipitation_probability': 10, + 'temperature': 11.0, + 'wind_bearing': 'N', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-28T21:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 10.0, + 'wind_bearing': 'NNE', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T00:00:00+00:00', + 'precipitation_probability': 6, + 'temperature': 9.0, + 'wind_bearing': 'E', + 'wind_speed': 6.44, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2020-04-29T03:00:00+00:00', + 'precipitation_probability': 3, + 'temperature': 8.0, + 'wind_bearing': 'SSE', + 'wind_speed': 11.27, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T06:00:00+00:00', + 'precipitation_probability': 9, + 'temperature': 8.0, + 'wind_bearing': 'SE', + 'wind_speed': 14.48, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T09:00:00+00:00', + 'precipitation_probability': 12, + 'temperature': 10.0, + 'wind_bearing': 'SE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T12:00:00+00:00', + 'precipitation_probability': 47, + 'temperature': 12.0, + 'wind_bearing': 'SE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'pouring', + 'datetime': '2020-04-29T15:00:00+00:00', + 'precipitation_probability': 59, + 'temperature': 13.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2020-04-29T18:00:00+00:00', + 'precipitation_probability': 39, + 'temperature': 12.0, + 'wind_bearing': 'SSE', + 'wind_speed': 17.7, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2020-04-29T21:00:00+00:00', + 'precipitation_probability': 19, + 'temperature': 11.0, + 'wind_bearing': 'SSE', + 'wind_speed': 20.92, + }), + ]) +# --- diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 673475c0303..6c6041b1869 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -2,13 +2,22 @@ import datetime from datetime import timedelta import json +from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory import pytest import requests_mock +from requests_mock.adapter import _Matcher +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.metoffice.const import DOMAIN +from homeassistant.components.metoffice.const import DEFAULT_SCAN_INTERVAL, DOMAIN +from homeassistant.components.weather import ( + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.util import utcnow @@ -21,6 +30,43 @@ from .const import ( ) from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def no_sensor(): + """Remove sensors.""" + with patch( + "homeassistant.components.metoffice.sensor.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +async def wavertree_data(requests_mock: requests_mock.Mocker) -> dict[str, _Matcher]: + """Mock data for the Wavertree location.""" + # all metoffice test data encapsulated in here + mock_json = json.loads(load_fixture("metoffice.json")) + all_sites = json.dumps(mock_json["all_sites"]) + wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) + wavertree_daily = json.dumps(mock_json["wavertree_daily"]) + + sitelist_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/sitelist/", text=all_sites + ) + wavertree_hourly_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/354107?res=3hourly", + text=wavertree_hourly, + ) + wavertree_daily_mock = requests_mock.get( + "/public/data/val/wxfcs/all/json/354107?res=daily", + text=wavertree_daily, + ) + return { + "sitelist_mock": sitelist_mock, + "wavertree_hourly_mock": wavertree_hourly_mock, + "wavertree_daily_mock": wavertree_daily_mock, + } @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) @@ -54,22 +100,17 @@ async def test_site_cannot_connect( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_site_cannot_update( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test we handle cannot connect error.""" + registry = er.async_get(hass) - # all metoffice test data encapsulated in here - mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", ) entry = MockConfigEntry( @@ -102,24 +143,17 @@ async def test_site_cannot_update( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_one_weather_site_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test the Met Office weather platform.""" + registry = er.async_get(hass) - # all metoffice test data encapsulated in here - mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) - - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", - text=wavertree_hourly, - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", - text=wavertree_daily, + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", ) entry = MockConfigEntry( @@ -185,25 +219,30 @@ async def test_one_weather_site_running( @pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) async def test_two_weather_sites_running( - hass: HomeAssistant, requests_mock: requests_mock.Mocker + hass: HomeAssistant, requests_mock: requests_mock.Mocker, wavertree_data ) -> None: """Test we handle two different weather sites both running.""" + registry = er.async_get(hass) + + # Pre-create the hourly entities + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "52.75556_0.44231", + suggested_object_id="met_office_king_s_lynn_3_hourly", + ) # all metoffice test data encapsulated in here mock_json = json.loads(load_fixture("metoffice.json")) - all_sites = json.dumps(mock_json["all_sites"]) - wavertree_hourly = json.dumps(mock_json["wavertree_hourly"]) - wavertree_daily = json.dumps(mock_json["wavertree_daily"]) kingslynn_hourly = json.dumps(mock_json["kingslynn_hourly"]) kingslynn_daily = json.dumps(mock_json["kingslynn_daily"]) - requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly - ) - requests_mock.get( - "/public/data/val/wxfcs/all/json/354107?res=daily", text=wavertree_daily - ) requests_mock.get( "/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly ) @@ -327,3 +366,214 @@ async def test_two_weather_sites_running( assert weather.attributes.get("forecast")[2]["temperature"] == 11 assert weather.attributes.get("forecast")[2]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE" + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_new_config_entry(hass: HomeAssistant, no_sensor, wavertree_data) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 1 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 1 + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_legacy_config_entry( + hass: HomeAssistant, no_sensor, wavertree_data +) -> None: + """Test the expected entities are created.""" + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids("weather")) == 2 + entry = hass.config_entries.async_entries()[0] + assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 2 + + +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_forecast_service( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + requests_mock: requests_mock.Mocker, + snapshot: SnapshotAssertion, + no_sensor, + wavertree_data: dict[str, _Matcher], +) -> None: + """Test multiple forecast.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert wavertree_data["wavertree_daily_mock"].call_count == 1 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should use cached data + assert wavertree_data["wavertree_daily_mock"].call_count == 1 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + # Trigger data refetch + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert wavertree_data["wavertree_daily_mock"].call_count == 2 + assert wavertree_data["wavertree_hourly_mock"].call_count == 1 + + for forecast_type in ("daily", "hourly"): + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": forecast_type, + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + # Calling the services should update the hourly forecast + assert wavertree_data["wavertree_daily_mock"].call_count == 2 + assert wavertree_data["wavertree_hourly_mock"].call_count == 2 + + # Update fails + requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") + + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.met_office_wavertree_daily", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] == [] + + +@pytest.mark.parametrize( + "entity_id", + [ + "weather.met_office_wavertree_3_hourly", + "weather.met_office_wavertree_daily", + ], +) +@pytest.mark.freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.UTC)) +async def test_forecast_subscription( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, + snapshot: SnapshotAssertion, + no_sensor, + wavertree_data: dict[str, _Matcher], + entity_id: str, +) -> None: + """Test multiple forecast.""" + client = await hass_ws_client(hass) + + registry = er.async_get(hass) + # Pre-create the hourly entity + registry.async_get_or_create( + WEATHER_DOMAIN, + DOMAIN, + "53.38374_-2.90929", + suggested_object_id="met_office_wavertree_3_hourly", + ) + + entry = MockConfigEntry( + domain=DOMAIN, + data=METOFFICE_CONFIG_WAVERTREE, + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + for forecast_type in ("daily", "hourly"): + await client.send_json_auto_id( + { + "type": "weather/subscribe_forecast", + "forecast_type": forecast_type, + "entity_id": entity_id, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + subscription_id = msg["id"] + + msg = await client.receive_json() + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast1 = msg["event"]["forecast"] + + assert forecast1 != [] + assert forecast1 == snapshot + + freezer.tick(DEFAULT_SCAN_INTERVAL + timedelta(seconds=1)) + await hass.async_block_till_done() + msg = await client.receive_json() + + assert msg["id"] == subscription_id + assert msg["type"] == "event" + forecast2 = msg["event"]["forecast"] + + assert forecast2 != [] + assert forecast2 == snapshot + + await client.send_json_auto_id( + { + "type": "unsubscribe_events", + "subscription": subscription_id, + } + ) + msg = await client.receive_json() + assert msg["success"] From 60fa02c042235cb14aee68b3b34186990393341f Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 10 Oct 2023 17:23:58 +0200 Subject: [PATCH 336/968] Bump pyOverkiz to 3.11 and migrate unique ids for select entries (#101024) * Bump pyOverkiz and migrate entries * Add comment * Remove entities when duplicate * Remove old entity * Remove old entities * Add example of entity migration * Add support of UIWidget and UIClass * Add tests for migrations * Apply feedback (1) * Apply feedback (2) --- homeassistant/components/overkiz/__init__.py | 66 +++++++++++++- .../components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/overkiz/test_init.py | 89 +++++++++++++++++++ 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 tests/components/overkiz/test_init.py diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index ab463fc34d9..6ca082ace76 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -8,6 +8,7 @@ from dataclasses import dataclass from aiohttp import ClientError, ServerDisconnectedError from pyoverkiz.client import OverkizClient from pyoverkiz.const import SUPPORTED_SERVERS +from pyoverkiz.enums import OverkizState, UIClass, UIWidget from pyoverkiz.exceptions import ( BadCredentialsException, MaintenanceException, @@ -17,9 +18,9 @@ from pyoverkiz.models import Device, Scenario from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -55,6 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: username=username, password=password, session=session, server=server ) + await _async_migrate_entries(hass, entry) + try: await client.login() @@ -144,3 +147,62 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> bool: + """Migrate old entries to new unique IDs.""" + entity_registry = er.async_get(hass) + + @callback + def update_unique_id(entry: er.RegistryEntry) -> dict[str, str] | None: + # Python 3.11 treats (str, Enum) and StrEnum in a different way + # Since pyOverkiz switched to StrEnum, we need to rewrite the unique ids once to the new style + # + # io://xxxx-xxxx-xxxx/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL -> io://xxxx-xxxx-xxxx/3541212-core:DiscreteRSSILevelState + # internal://xxxx-xxxx-xxxx/alarm/0-UIWidget.TSKALARM_CONTROLLER -> internal://xxxx-xxxx-xxxx/alarm/0-TSKAlarmController + # io://xxxx-xxxx-xxxx/xxxxxxx-UIClass.ON_OFF -> io://xxxx-xxxx-xxxx/xxxxxxx-OnOff + if (key := entry.unique_id.split("-")[-1]).startswith( + ("OverkizState", "UIWidget", "UIClass") + ): + state = key.split(".")[1] + new_key = "" + + if key.startswith("UIClass"): + new_key = UIClass[state] + elif key.startswith("UIWidget"): + new_key = UIWidget[state] + else: + new_key = OverkizState[state] + + new_unique_id = entry.unique_id.replace(key, new_key) + + LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + LOGGER.debug( + "Cannot migrate to unique_id '%s', already exists for '%s'. Entity will be removed", + new_unique_id, + existing_entity_id, + ) + entity_registry.async_remove(entry.entity_id) + + return None + + return { + "new_unique_id": new_unique_id, + } + + return None + + await er.async_migrate_entries(hass, config_entry.entry_id, update_unique_id) + + return True diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index d88996c7e02..4e1fdee989a 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.9.0"], + "requirements": ["pyoverkiz==1.11.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index e5eeaaa48f6..170081bd89f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.11.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f08b72228d..6e5a34d83ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1450,7 +1450,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.9.0 +pyoverkiz==1.11.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/tests/components/overkiz/test_init.py b/tests/components/overkiz/test_init.py new file mode 100644 index 00000000000..774f3c9a79a --- /dev/null +++ b/tests/components/overkiz/test_init.py @@ -0,0 +1,89 @@ +"""Tests for Overkiz integration init.""" +from homeassistant.components.overkiz.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from .test_config_flow import TEST_EMAIL, TEST_GATEWAY_ID, TEST_HUB, TEST_PASSWORD + +from tests.common import MockConfigEntry, mock_registry + +ENTITY_SENSOR_DISCRETE_RSSI_LEVEL = "sensor.zipscreen_woonkamer_discrete_rssi_level" +ENTITY_ALARM_CONTROL_PANEL = "alarm_control_panel.alarm" +ENTITY_SWITCH_GARAGE = "switch.garage" +ENTITY_SENSOR_TARGET_CLOSURE_STATE = "sensor.zipscreen_woonkamer_target_closure_state" +ENTITY_SENSOR_TARGET_CLOSURE_STATE_2 = ( + "sensor.zipscreen_woonkamer_target_closure_state_2" +) + + +async def test_unique_id_migration(hass: HomeAssistant) -> None: + """Test migration of sensor unique IDs.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_GATEWAY_ID, + data={"username": TEST_EMAIL, "password": TEST_PASSWORD, "hub": TEST_HUB}, + ) + + mock_entry.add_to_hass(hass) + + mock_registry( + hass, + { + # This entity will be migrated to "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState" + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: er.RegistryEntry( + entity_id=ENTITY_SENSOR_DISCRETE_RSSI_LEVEL, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_DISCRETE_RSSI_LEVEL", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "internal://1234-5678-1234/alarm/0-TSKAlarmController" + ENTITY_ALARM_CONTROL_PANEL: er.RegistryEntry( + entity_id=ENTITY_ALARM_CONTROL_PANEL, + unique_id="internal://1234-5678-1234/alarm/0-UIWidget.TSKALARM_CONTROLLER", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be migrated to "io://1234-5678-1234/0-OnOff" + ENTITY_SWITCH_GARAGE: er.RegistryEntry( + entity_id=ENTITY_SWITCH_GARAGE, + unique_id="io://1234-5678-1234/0-UIClass.ON_OFF", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will be removed since "io://1234-5678-1234/3541212-core:TargetClosureState" already exists + ENTITY_SENSOR_TARGET_CLOSURE_STATE: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE, + unique_id="io://1234-5678-1234/3541212-OverkizState.CORE_TARGET_CLOSURE", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + # This entity will not be migrated" + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: er.RegistryEntry( + entity_id=ENTITY_SENSOR_TARGET_CLOSURE_STATE_2, + unique_id="io://1234-5678-1234/3541212-core:TargetClosureState", + platform=DOMAIN, + config_entry_id=mock_entry.entry_id, + ), + }, + ) + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ent_reg = er.async_get(hass) + + unique_id_map = { + ENTITY_SENSOR_DISCRETE_RSSI_LEVEL: "io://1234-5678-1234/3541212-core:DiscreteRSSILevelState", + ENTITY_ALARM_CONTROL_PANEL: "internal://1234-5678-1234/alarm/0-TSKAlarmController", + ENTITY_SWITCH_GARAGE: "io://1234-5678-1234/0-OnOff", + ENTITY_SENSOR_TARGET_CLOSURE_STATE_2: "io://1234-5678-1234/3541212-core:TargetClosureState", + } + + # Test if entities will be removed + assert set(ent_reg.entities.keys()) == set(unique_id_map) + + # Test if unique ids are migrated + for entity_id, unique_id in unique_id_map.items(): + entry = ent_reg.async_get(entity_id) + assert entry.unique_id == unique_id From f7f9331c5734ef92901822ee8545ad7417a361ca Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 10 Oct 2023 17:37:02 +0200 Subject: [PATCH 337/968] Bump pyDuotecno to 2023.10.0 (#101754) --- 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 d04e883f867..c7885496af8 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyDuotecno==2023.9.0"] + "requirements": ["pyDuotecno==2023.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 170081bd89f..d0720fb7b09 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.9.0 +pyDuotecno==2023.10.0 # homeassistant.components.eight_sleep pyEight==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e5a34d83ec..7a375f861a5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1171,7 +1171,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.9.0 +pyDuotecno==2023.10.0 # homeassistant.components.eight_sleep pyEight==0.3.2 From f166e1cc1a80a987b2464b5e06f677f65d9e457a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Oct 2023 05:55:42 -1000 Subject: [PATCH 338/968] Map switch device class outlet to Outlets in homekit (#101760) --- homeassistant/components/homekit/accessories.py | 9 +++++++-- tests/components/homekit/test_get_accessories.py | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 5a1e9bc1ea2..d2b733cd88d 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -17,6 +17,7 @@ from homeassistant.components.cover import CoverDeviceClass, CoverEntityFeature from homeassistant.components.media_player import MediaPlayerDeviceClass from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, @@ -228,8 +229,12 @@ def get_accessory( # noqa: C901 a_type = "LightSensor" elif state.domain == "switch": - switch_type = config.get(CONF_TYPE, TYPE_SWITCH) - a_type = SWITCH_TYPES[switch_type] + if switch_type := config.get(CONF_TYPE): + a_type = SWITCH_TYPES[switch_type] + elif state.attributes.get(ATTR_DEVICE_CLASS) == SwitchDeviceClass.OUTLET: + a_type = "Outlet" + else: + a_type = "Switch" elif state.domain == "vacuum": a_type = "Vacuum" diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index 960647a22e6..179a0ce467f 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -22,6 +22,7 @@ from homeassistant.components.media_player import ( MediaPlayerEntityFeature, ) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.const import ( ATTR_CODE, @@ -315,6 +316,13 @@ def test_type_sensors(type_name, entity_id, state, attrs) -> None: ("type_name", "entity_id", "state", "attrs", "config"), [ ("Outlet", "switch.test", "on", {}, {CONF_TYPE: TYPE_OUTLET}), + ( + "Outlet", + "switch.test", + "on", + {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET}, + {}, + ), ("Switch", "automation.test", "on", {}, {}), ("Switch", "button.test", STATE_UNKNOWN, {}, {}), ("Switch", "input_boolean.test", "on", {}, {}), From 7b4b8e751689040d856a06f9a14ead5c21f0edbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Oct 2023 06:20:25 -1000 Subject: [PATCH 339/968] Refactor HomeKit to allow supported features/device class to change (#101719) --- homeassistant/components/homekit/__init__.py | 159 +++++--- .../components/homekit/accessories.py | 67 ++- .../components/homekit/type_cameras.py | 5 +- homeassistant/components/homekit/type_fans.py | 7 + .../components/homekit/type_humidifiers.py | 7 + .../components/homekit/type_lights.py | 8 +- .../components/homekit/type_remotes.py | 4 +- .../components/homekit/type_thermostats.py | 40 +- .../components/homekit/type_triggers.py | 6 +- tests/components/homekit/test_homekit.py | 144 ++++++- tests/components/homekit/test_type_fans.py | 35 +- .../homekit/test_type_humidifiers.py | 10 +- tests/components/homekit/test_type_lights.py | 82 +++- .../homekit/test_type_media_players.py | 71 ++-- tests/components/homekit/test_type_remote.py | 74 ++-- tests/components/homekit/test_type_sensors.py | 14 +- .../homekit/test_type_thermostats.py | 380 +++++++----------- .../components/homekit/test_type_triggers.py | 1 + 18 files changed, 662 insertions(+), 452 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index bb4efb7db6c..c3b7bf5d2e6 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -47,7 +47,14 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, State, callback +from homeassistant.core import ( + CALLBACK_TYPE, + CoreState, + HomeAssistant, + ServiceCall, + State, + callback, +) from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import ( config_validation as cv, @@ -55,6 +62,7 @@ from homeassistant.helpers import ( entity_registry as er, instance_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entityfilter import ( BASE_FILTER_SCHEMA, FILTER_SCHEMA, @@ -534,6 +542,7 @@ class HomeKit: self.driver: HomeDriver | None = None self.bridge: HomeBridge | None = None self._reset_lock = asyncio.Lock() + self._cancel_reload_dispatcher: CALLBACK_TYPE | None = None def setup(self, async_zeroconf_instance: AsyncZeroconf, uuid: str) -> None: """Set up bridge and accessory driver.""" @@ -563,16 +572,28 @@ class HomeKit: async def async_reset_accessories(self, entity_ids: Iterable[str]) -> None: """Reset the accessory to load the latest configuration.""" + _LOGGER.debug("Resetting accessories: %s", entity_ids) async with self._reset_lock: if not self.bridge: - await self.async_reset_accessories_in_accessory_mode(entity_ids) + # For accessory mode reset and reload are the same + await self._async_reload_accessories_in_accessory_mode(entity_ids) return - await self.async_reset_accessories_in_bridge_mode(entity_ids) + await self._async_reset_accessories_in_bridge_mode(entity_ids) - async def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: + async def async_reload_accessories(self, entity_ids: Iterable[str]) -> None: + """Reload the accessory to load the latest configuration.""" + _LOGGER.debug("Reloading accessories: %s", entity_ids) + async with self._reset_lock: + if not self.bridge: + await self._async_reload_accessories_in_accessory_mode(entity_ids) + return + await self._async_reload_accessories_in_bridge_mode(entity_ids) + + @callback + def _async_shutdown_accessory(self, accessory: HomeAccessory) -> None: """Shutdown an accessory.""" assert self.driver is not None - await accessory.stop() + accessory.async_stop() # Deallocate the IIDs for the accessory iid_manager = accessory.iid_manager services: list[Service] = accessory.services @@ -582,7 +603,7 @@ class HomeKit: for char in characteristics: iid_manager.remove_obj(char) - async def async_reset_accessories_in_accessory_mode( + async def _async_reload_accessories_in_accessory_mode( self, entity_ids: Iterable[str] ) -> None: """Reset accessories in accessory mode.""" @@ -593,63 +614,88 @@ class HomeKit: return if not (state := self.hass.states.get(acc.entity_id)): _LOGGER.warning( - "The underlying entity %s disappeared during reset", acc.entity_id + "The underlying entity %s disappeared during reload", acc.entity_id ) return - await self._async_shutdown_accessory(acc) + self._async_shutdown_accessory(acc) if new_acc := self._async_create_single_accessory([state]): self.driver.accessory = new_acc - self.hass.async_create_task( - new_acc.run(), f"HomeKit Bridge Accessory: {new_acc.entity_id}" - ) - await self.async_config_changed() + # Run must be awaited here since it may change + # the accessories hash + await new_acc.run() + self._async_update_accessories_hash() - async def async_reset_accessories_in_bridge_mode( + def _async_remove_accessories_by_entity_id( self, entity_ids: Iterable[str] - ) -> None: - """Reset accessories in bridge mode.""" + ) -> list[str]: + """Remove accessories by entity id.""" assert self.aid_storage is not None assert self.bridge is not None - assert self.driver is not None - - new = [] + removed: list[str] = [] acc: HomeAccessory | None for entity_id in entity_ids: aid = self.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) if aid not in self.bridge.accessories: continue - _LOGGER.info( - "HomeKit Bridge %s will reset accessory with linked entity_id %s", - self._name, - entity_id, - ) - acc = await self.async_remove_bridge_accessory(aid) - if acc: - await self._async_shutdown_accessory(acc) - if acc and (state := self.hass.states.get(acc.entity_id)): - new.append(state) - else: - _LOGGER.warning( - "The underlying entity %s disappeared during reset", entity_id - ) + if acc := self.async_remove_bridge_accessory(aid): + self._async_shutdown_accessory(acc) + removed.append(entity_id) + return removed - if not new: - # No matched accessories, probably on another bridge + async def _async_reset_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reset accessories in bridge mode.""" + if not (removed := self._async_remove_accessories_by_entity_id(entity_ids)): + _LOGGER.debug("No accessories to reset in bridge mode for: %s", entity_ids) return - - await self.async_config_changed() - await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) - for state in new: - if acc := self.add_bridge_accessory(state): - self.hass.async_create_task( - acc.run(), f"HomeKit Bridge Accessory: {acc.entity_id}" - ) - await self.async_config_changed() - - async def async_config_changed(self) -> None: - """Call config changed which writes out the new config to disk.""" + # With a reset, we need to remove the accessories, + # and force config change so iCloud deletes them from + # the database. assert self.driver is not None - await self.hass.async_add_executor_job(self.driver.config_changed) + self._async_update_accessories_hash() + await asyncio.sleep(_HOMEKIT_CONFIG_UPDATE_TIME) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_reload_accessories_in_bridge_mode( + self, entity_ids: Iterable[str] + ) -> None: + """Reload accessories in bridge mode.""" + removed = self._async_remove_accessories_by_entity_id(entity_ids) + await self._async_recreate_removed_accessories_in_bridge_mode(removed) + + async def _async_recreate_removed_accessories_in_bridge_mode( + self, removed: list[str] + ) -> None: + """Recreate removed accessories in bridge mode.""" + for entity_id in removed: + if not (state := self.hass.states.get(entity_id)): + _LOGGER.warning( + "The underlying entity %s disappeared during reload", entity_id + ) + continue + if acc := self.add_bridge_accessory(state): + # Run must be awaited here since it may change + # the accessories hash + await acc.run() + self._async_update_accessories_hash() + + @callback + def _async_update_accessories_hash(self) -> bool: + """Update the accessories hash.""" + assert self.driver is not None + driver = self.driver + old_hash = driver.state.accessories_hash + new_hash = driver.accessories_hash + if driver.state.set_accessories_hash(new_hash): + _LOGGER.debug( + "Updating HomeKit accessories hash from %s -> %s", old_hash, new_hash + ) + driver.async_persist() + driver.async_update_advertisement() + return True + _LOGGER.debug("HomeKit accessories hash is unchanged: %s", new_hash) + return False def add_bridge_accessory(self, state: State) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" @@ -734,7 +780,8 @@ class HomeKit: ) ) - async def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: + @callback + def async_remove_bridge_accessory(self, aid: int) -> HomeAccessory | None: """Try adding accessory to bridge if configured beforehand.""" assert self.bridge is not None if acc := self.bridge.accessories.pop(aid, None): @@ -782,6 +829,11 @@ class HomeKit: if self.status != STATUS_READY: return self.status = STATUS_WAIT + self._cancel_reload_dispatcher = async_dispatcher_connect( + self.hass, + f"homekit_reload_entities_{self._entry_id}", + self.async_reload_accessories, + ) async_zc_instance = await zeroconf.async_get_async_instance(self.hass) uuid = await instance_id.async_get(self.hass) self.aid_storage = AccessoryAidStorage(self.hass, self._entry_id) @@ -989,10 +1041,13 @@ class HomeKit: """Stop the accessory driver.""" if self.status != STATUS_RUNNING: return - self.status = STATUS_STOPPED - _LOGGER.debug("Driver stop for %s", self._name) - if self.driver: - await self.driver.async_stop() + async with self._reset_lock: + self.status = STATUS_STOPPED + assert self._cancel_reload_dispatcher is not None + self._cancel_reload_dispatcher() + _LOGGER.debug("Driver stop for %s", self._name) + if self.driver: + await self.driver.async_stop() @callback def _async_configure_linked_sensors( diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index d2b733cd88d..2e0d1e6c052 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -47,6 +47,7 @@ from homeassistant.core import ( callback as ha_callback, split_entity_id, ) +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -69,7 +70,6 @@ from .const import ( CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, - DOMAIN, EVENT_HOMEKIT_CHANGED, HK_CHARGING, HK_NOT_CHARGABLE, @@ -81,7 +81,6 @@ from .const import ( MAX_VERSION_LENGTH, SERV_ACCESSORY_INFO, SERV_BATTERY_SERVICE, - SERVICE_HOMEKIT_RESET_ACCESSORY, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -111,6 +110,12 @@ SWITCH_TYPES = { } TYPES: Registry[str, type[HomeAccessory]] = Registry() +RELOAD_ON_CHANGE_ATTRS = ( + ATTR_SUPPORTED_FEATURES, + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, +) + def get_accessory( # noqa: C901 hass: HomeAssistant, driver: HomeDriver, state: State, aid: int | None, config: dict @@ -272,6 +277,8 @@ def get_accessory( # noqa: C901 class HomeAccessory(Accessory): # type: ignore[misc] """Adapter class for Accessory.""" + driver: HomeDriver + def __init__( self, hass: HomeAssistant, @@ -294,6 +301,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] *args, # noqa: B026 **kwargs, ) + self._reload_on_change_attrs = list(RELOAD_ON_CHANGE_ATTRS) self.config = config or {} if device_id: self.device_id: str | None = device_id @@ -464,7 +472,27 @@ class HomeAccessory(Accessory): # type: ignore[misc] self, event: EventType[EventStateChangedData] ) -> None: """Handle state change event listener callback.""" - self.async_update_state_callback(event.data["new_state"]) + new_state = event.data["new_state"] + old_state = event.data["old_state"] + if ( + new_state + and old_state + and STATE_UNAVAILABLE not in (old_state.state, new_state.state) + ): + old_attributes = old_state.attributes + new_attributes = new_state.attributes + for attr in self._reload_on_change_attrs: + if old_attributes.get(attr) != new_attributes.get(attr): + _LOGGER.debug( + "%s: Reloading HomeKit accessory since %s has changed from %s -> %s", + self.entity_id, + attr, + old_attributes.get(attr), + new_attributes.get(attr), + ) + self.async_reload() + return + self.async_update_state_callback(new_state) @ha_callback def async_update_state_callback(self, new_state: State | None) -> None: @@ -577,21 +605,30 @@ class HomeAccessory(Accessory): # type: ignore[misc] ) @ha_callback - def async_reset(self) -> None: - """Reset and recreate an accessory.""" - self.hass.async_create_task( - self.hass.services.async_call( - DOMAIN, - SERVICE_HOMEKIT_RESET_ACCESSORY, - {ATTR_ENTITY_ID: self.entity_id}, - ) + def async_reload(self) -> None: + """Reload and recreate an accessory and update the c# value in the mDNS record.""" + async_dispatcher_send( + self.hass, + f"homekit_reload_entities_{self.driver.entry_id}", + (self.entity_id,), ) - async def stop(self) -> None: + @ha_callback + def async_stop(self) -> None: """Cancel any subscriptions when the bridge is stopped.""" while self._subscriptions: self._subscriptions.pop(0)() + async def stop(self) -> None: + """Stop the accessory. + + This is overrides the parent class to call async_stop + since pyhap will call this function to stop the accessory + but we want to use our async_stop method since we need + it to be a callback to avoid races in reloading accessories. + """ + self.async_stop() + class HomeBridge(Bridge): # type: ignore[misc] """Adapter class for Bridge.""" @@ -637,7 +674,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Initialize a AccessoryDriver object.""" super().__init__(**kwargs) self.hass = hass - self._entry_id = entry_id + self.entry_id = entry_id self._bridge_name = bridge_name self._entry_title = entry_title self.iid_storage = iid_storage @@ -649,7 +686,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] """Override super function to dismiss setup message if paired.""" success = super().pair(client_username_bytes, client_public, client_permissions) if success: - async_dismiss_setup_message(self.hass, self._entry_id) + async_dismiss_setup_message(self.hass, self.entry_id) return cast(bool, success) @pyhap_callback # type: ignore[misc] @@ -662,7 +699,7 @@ class HomeDriver(AccessoryDriver): # type: ignore[misc] async_show_setup_message( self.hass, - self._entry_id, + self.entry_id, accessory_friendly_name(self._entry_title, self.accessory), self.state.pincode, self.accessory.xhm_uri(), diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 4c7ba5a7841..63b2bc023da 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -447,13 +447,14 @@ class Camera(HomeAccessory, PyhapCamera): self.sessions[session_id].pop(FFMPEG_WATCHER)() self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() - async def stop(self): + @callback + def async_stop(self): """Stop any streams when the accessory is stopped.""" for session_info in self.sessions.values(): self.hass.async_create_background_task( self.stop_stream(session_info), "homekit.camera-stop-stream" ) - await super().stop() + super().async_stop() async def stop_stream(self, session_info): """Stop the stream for the given ``session_id``.""" diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index e3116c99e26..0ace0acd0b9 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -60,6 +60,13 @@ class Fan(HomeAccessory): self.chars = [] state = self.hass.states.get(self.entity_id) + self._reload_on_change_attrs.extend( + ( + ATTR_PERCENTAGE_STEP, + ATTR_PRESET_MODES, + ) + ) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1) self.preset_modes = state.attributes.get(ATTR_PRESET_MODES) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index f9f572a096c..de25717877c 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -76,6 +76,13 @@ class HumidifierDehumidifier(HomeAccessory): def __init__(self, *args): """Initialize a HumidifierDehumidifier accessory object.""" super().__init__(*args, category=CATEGORY_HUMIDIFIER) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_HUMIDITY, + ATTR_MIN_HUMIDITY, + ) + ) + self.chars = [] state = self.hass.states.get(self.entity_id) device_class = state.attributes.get( diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 83ce1c3f6cf..e8272358633 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -71,7 +71,13 @@ class Light(HomeAccessory): def __init__(self, *args): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) - + self._reload_on_change_attrs.extend( + ( + ATTR_SUPPORTED_COLOR_MODES, + ATTR_MAX_COLOR_TEMP_KELVIN, + ATTR_MIN_COLOR_TEMP_KELVIN, + ) + ) self.chars = [] self._event_timer = None self._pending_events = {} diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index e440a5b3ac0..5dfc9777964 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -93,7 +93,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): state = self.hass.states.get(self.entity_id) assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - + self._reload_on_change_attrs.extend((source_list_key,)) self._mapped_sources_list: list[str] = [] self._mapped_sources: dict[str, str] = {} self.source_key = source_key @@ -204,8 +204,6 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): "%s: Sources out of sync. Rebuilding Accessory", self.entity_id, ) - # Sources are out of sync, recreate the accessory - self.async_reset() return _LOGGER.debug( diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index c34e9066160..85ad713012b 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -174,6 +174,15 @@ class Thermostat(HomeAccessory): self.hc_homekit_to_hass = None self.hc_hass_to_homekit = None hc_min_temp, hc_max_temp = self.get_temperature_range() + self._reload_on_change_attrs.extend( + ( + ATTR_MIN_HUMIDITY, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_FAN_MODES, + ATTR_HVAC_MODES, + ) + ) # Add additional characteristics if auto mode is supported self.chars = [] @@ -345,7 +354,7 @@ class Thermostat(HomeAccessory): ) self.char_target_fan_state.display_name = "Fan Auto" - self._async_update_state(state) + self.async_update_state(state) serv_thermostat.setter_callback = self._set_chars @@ -577,29 +586,6 @@ class Thermostat(HomeAccessory): @callback def async_update_state(self, new_state): - """Update thermostat state after state changed.""" - # We always recheck valid hvac modes as the entity - # may not have been fully setup when we saw it last - original_hc_hass_to_homekit = self.hc_hass_to_homekit - self._configure_hvac_modes(new_state) - - if self.hc_hass_to_homekit != original_hc_hass_to_homekit: - if self.char_target_heat_cool.value not in self.hc_homekit_to_hass: - # We must make sure the char value is - # in the new valid values before - # setting the new valid values or - # changing them with throw - self.char_target_heat_cool.set_value( - list(self.hc_homekit_to_hass)[0], should_notify=False - ) - self.char_target_heat_cool.override_properties( - valid_values=self.hc_hass_to_homekit - ) - - self._async_update_state(new_state) - - @callback - def _async_update_state(self, new_state): """Update state without rechecking the device features.""" attributes = new_state.attributes features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -727,6 +713,12 @@ class WaterHeater(HomeAccessory): def __init__(self, *args): """Initialize a WaterHeater accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) + self._reload_on_change_attrs.extend( + ( + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ) + ) self._unit = self.hass.config.units.temperature_unit min_temp, max_temp = self.get_temperature_range() diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index ee737e01ff4..be8db07d517 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -6,7 +6,7 @@ from typing import Any from pyhap.const import CATEGORY_SENSOR -from homeassistant.core import CALLBACK_TYPE, Context +from homeassistant.core import CALLBACK_TYPE, Context, callback from homeassistant.helpers import entity_registry as er from homeassistant.helpers.trigger import async_initialize_triggers @@ -112,10 +112,12 @@ class DeviceTriggerAccessory(HomeAccessory): _LOGGER.log, ) - async def stop(self) -> None: + @callback + def async_stop(self) -> None: """Handle accessory driver stop event.""" if self._remove_triggers: self._remove_triggers() + super().async_stop() @property def available(self) -> bool: diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 00281b491c4..ebb710561d9 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -36,7 +36,13 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id +from homeassistant.components.light import ( + ATTR_COLOR_MODE, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, +) from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.components.switch import SwitchDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -532,7 +538,7 @@ async def test_homekit_remove_accessory( acc_mock.stop = AsyncMock() homekit.bridge.accessories = {6: acc_mock} - acc = await homekit.async_remove_bridge_accessory(6) + acc = homekit.async_remove_bridge_accessory(6) assert acc is acc_mock assert len(homekit.bridge.accessories) == 0 @@ -876,6 +882,7 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: # Test if driver is started homekit.status = STATUS_RUNNING + homekit._cancel_reload_dispatcher = lambda: None await homekit.async_stop() await hass.async_block_till_done() assert homekit.driver.async_stop.called is True @@ -919,6 +926,120 @@ async def test_homekit_reset_accessories( await homekit.async_stop() +async def test_homekit_reload_accessory_can_change_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in brdige mode. + + This test ensure when device class changes the HomeKit class changes. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + switch_accessory = next(iter(bridge.accessories.values())) + assert type(switch_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + outlet_accessory = next(iter(bridge.accessories.values())) + assert type(outlet_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_in_accessory_mode( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in accessory mode. + + This test ensure a device class changes can change the class of + the accessory. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "switch.outlet" + hass.states.async_set(entity_id, "on", {ATTR_DEVICE_CLASS: None}) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + primary_accessory = homekit.driver.accessory + await primary_accessory.run() + assert type(primary_accessory).__name__ == "Switch" + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, "off", {ATTR_DEVICE_CLASS: SwitchDeviceClass.OUTLET} + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + primary_accessory = homekit.driver.accessory + assert type(primary_accessory).__name__ == "Outlet" + + await homekit.async_stop() + + +async def test_homekit_reload_accessory_same_class( + hass: HomeAssistant, mock_async_zeroconf: None, mock_hap +) -> None: + """Test reloading a HomeKit Accessory in bridge mode. + + The class of the accessory remains the same. + """ + + entry = MockConfigEntry( + domain=DOMAIN, data={CONF_NAME: "mock_name", CONF_PORT: 12345} + ) + entity_id = "light.color" + hass.states.async_set( + entity_id, + "on", + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS}, + ) + homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE) + + with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit): + await async_init_entry(hass, entry) + bridge: HomeBridge = homekit.driver.accessory + await bridge.run() + light_accessory_color = next(iter(bridge.accessories.values())) + assert not hasattr(light_accessory_color, "char_color_temp") + await hass.async_block_till_done() + assert homekit.status == STATUS_RUNNING + homekit.driver.aio_stop_event = MagicMock() + hass.states.async_set( + entity_id, + "on", + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS, ColorMode.COLOR_TEMP], + ATTR_COLOR_MODE: ColorMode.COLOR_TEMP, + }, + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + light_accessory_color_and_temp = next(iter(bridge.accessories.values())) + assert hasattr(light_accessory_color_and_temp, "char_color_temp") + + await homekit.async_stop() + + async def test_homekit_unpair( hass: HomeAssistant, device_registry: dr.DeviceRegistry, mock_async_zeroconf: None ) -> None: @@ -1076,8 +1197,8 @@ async def test_homekit_reset_accessories_not_supported( with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 @@ -1101,7 +1222,7 @@ async def test_homekit_reset_accessories_not_supported( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 2 + assert hk_driver_async_update_advertisement.call_count == 1 assert not mock_add_accessory.called assert len(homekit.bridge.accessories) == 0 homekit.status = STATUS_STOPPED @@ -1165,22 +1286,25 @@ async def test_homekit_reset_accessories_not_bridged( with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( "pyhap.accessory.Bridge.add_accessory" ) as mock_add_accessory, patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch.object( homekit_base, "_HOMEKIT_CONFIG_UPDATE_TIME", 0 ): await async_init_entry(hass, entry) + assert hk_driver_async_update_advertisement.call_count == 0 acc_mock = MagicMock() acc_mock.entity_id = entity_id acc_mock.stop = AsyncMock() + acc_mock.to_HAP = lambda: {} aid = homekit.aid_storage.get_or_allocate_aid_for_entity_id(entity_id) homekit.bridge.accessories = {aid: acc_mock} homekit.status = STATUS_RUNNING homekit.driver.aio_stop_event = MagicMock() + assert hk_driver_async_update_advertisement.call_count == 0 await hass.services.async_call( DOMAIN, @@ -1190,7 +1314,7 @@ async def test_homekit_reset_accessories_not_bridged( ) await hass.async_block_till_done() - assert hk_driver_config_changed.call_count == 0 + assert hk_driver_async_update_advertisement.call_count == 0 assert not mock_add_accessory.called homekit.status = STATUS_STOPPED @@ -1208,8 +1332,8 @@ async def test_homekit_reset_single_accessory( homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_ACCESSORY) with patch(f"{PATH_HOMEKIT}.HomeKit", return_value=homekit), patch( - "pyhap.accessory_driver.AccessoryDriver.config_changed" - ) as hk_driver_config_changed, patch( + "pyhap.accessory_driver.AccessoryDriver.async_update_advertisement" + ) as hk_driver_async_update_advertisement, patch( "pyhap.accessory_driver.AccessoryDriver.async_start" ), patch( f"{PATH_HOMEKIT}.accessories.HomeAccessory.run" @@ -1226,7 +1350,7 @@ async def test_homekit_reset_single_accessory( ) await hass.async_block_till_done() assert mock_run.called - assert hk_driver_config_changed.call_count == 1 + assert hk_driver_async_update_advertisement.call_count == 1 homekit.status = STATUS_READY await homekit.async_stop() diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py index c39a3ea97c9..df54cce1b3f 100644 --- a/tests/components/homekit/test_type_fans.py +++ b/tests/components/homekit/test_type_fans.py @@ -129,7 +129,14 @@ async def test_fan_direction(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_direction.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_DIRECTION: DIRECTION_REVERSE}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_FEATURES: FanEntityFeature.DIRECTION, + ATTR_DIRECTION: DIRECTION_REVERSE, + }, + ) await hass.async_block_till_done() assert acc.char_direction.value == 1 @@ -197,7 +204,11 @@ async def test_fan_oscillate(hass: HomeAssistant, hk_driver, events) -> None: await hass.async_block_till_done() assert acc.char_swing.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_FEATURES: FanEntityFeature.OSCILLATE, ATTR_OSCILLATING: True}, + ) await hass.async_block_till_done() assert acc.char_swing.value == 1 @@ -272,7 +283,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: await acc.run() await hass.async_block_till_done() - hass.states.async_set(entity_id, STATE_ON, {ATTR_PERCENTAGE: 100}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 100, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 100 @@ -306,7 +325,15 @@ async def test_fan_speed(hass: HomeAssistant, hk_driver, events) -> None: assert events[-1].data[ATTR_VALUE] == 42 # Verify speed is preserved from off to on - hass.states.async_set(entity_id, STATE_OFF, {ATTR_PERCENTAGE: 42}) + hass.states.async_set( + entity_id, + STATE_OFF, + { + ATTR_PERCENTAGE_STEP: 25, + ATTR_SUPPORTED_FEATURES: FanEntityFeature.SET_SPEED, + ATTR_PERCENTAGE: 42, + }, + ) await hass.async_block_till_done() assert acc.char_speed.value == 50 assert acc.char_active.value == 0 diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index f3e4f96573d..e0b3e40967f 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -48,7 +48,9 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: """Test if humidifier accessory and HA are updated accordingly.""" entity_id = "humidifier.test" - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set( + entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER} + ) await hass.async_block_till_done() acc = HumidifierDehumidifier( hass, hk_driver, "HumidifierDehumidifier", entity_id, 1, None @@ -77,7 +79,7 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 47}, + {ATTR_HUMIDITY: 47, ATTR_DEVICE_CLASS: HumidifierDeviceClass.HUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 47.0 @@ -158,7 +160,7 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_ON, - {ATTR_HUMIDITY: 30}, + {ATTR_HUMIDITY: 30, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 30.0 @@ -169,7 +171,7 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, STATE_OFF, - {ATTR_HUMIDITY: 42}, + {ATTR_HUMIDITY: 42, ATTR_DEVICE_CLASS: HumidifierDeviceClass.DEHUMIDIFIER}, ) await hass.async_block_till_done() assert acc.char_target_humidity.value == 42.0 diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 53d310d8e40..b023b7255a8 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -122,7 +122,8 @@ async def test_light_basic(hass: HomeAssistant, hk_driver, events) -> None: @pytest.mark.parametrize( - "supported_color_modes", [["brightness"], ["hs"], ["color_temp"]] + "supported_color_modes", + [[ColorMode.BRIGHTNESS], [ColorMode.HS], [ColorMode.COLOR_TEMP]], ) async def test_light_brightness( hass: HomeAssistant, hk_driver, events, supported_color_modes @@ -149,7 +150,11 @@ async def test_light_brightness( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 @@ -222,24 +227,48 @@ async def test_light_brightness( # 0 is a special case for homekit, see "Handle Brightness" # in update_state - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 255}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 255}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 # Ensure floats are handled - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 55.66}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 55.66}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 22 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 108.4}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 108.4}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 43 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 0.0}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: supported_color_modes, ATTR_BRIGHTNESS: 0.0}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 1 @@ -490,7 +519,9 @@ async def test_light_color_temperature_and_rgb_color( assert acc.char_saturation.value == 100 -@pytest.mark.parametrize("supported_color_modes", [["hs"], ["rgb"], ["xy"]]) +@pytest.mark.parametrize( + "supported_color_modes", [[ColorMode.HS], [ColorMode.RGB], [ColorMode.XY]] +) async def test_light_rgb_color( hass: HomeAssistant, hk_driver, events, supported_color_modes ) -> None: @@ -1221,7 +1252,7 @@ async def test_light_set_brightness_and_color( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["hs"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 255, }, ) @@ -1241,11 +1272,19 @@ async def test_light_set_brightness_and_color( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_HS_COLOR: (4.5, 9.2)}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_HS_COLOR: (4.5, 9.2)}, + ) await hass.async_block_till_done() assert acc.char_hue.value == 4 assert acc.char_saturation.value == 9 @@ -1297,7 +1336,7 @@ async def test_light_min_max_mireds(hass: HomeAssistant, hk_driver, events) -> N entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, ATTR_MAX_MIREDS: 500.5, ATTR_MIN_MIREDS: 100.5, @@ -1319,7 +1358,7 @@ async def test_light_set_brightness_and_color_temp( entity_id, STATE_ON, { - ATTR_SUPPORTED_COLOR_MODES: ["color_temp"], + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 255, }, ) @@ -1338,11 +1377,22 @@ async def test_light_set_brightness_and_color_temp( await hass.async_block_till_done() assert acc.char_brightness.value == 100 - hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + hass.states.async_set( + entity_id, + STATE_ON, + {ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], ATTR_BRIGHTNESS: 102}, + ) await hass.async_block_till_done() assert acc.char_brightness.value == 40 - hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP_KELVIN: (4461)}) + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.COLOR_TEMP], + ATTR_COLOR_TEMP_KELVIN: (4461), + }, + ) await hass.async_block_till_done() assert acc.char_color_temp.value == 224 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py index 3842303ec84..104b9dd61ce 100644 --- a/tests/components/homekit/test_type_media_players.py +++ b/tests/components/homekit/test_type_media_players.py @@ -56,11 +56,12 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> } } entity_id = "media_player.test" + base_attrs = {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False} hass.states.async_set( entity_id, None, - {ATTR_SUPPORTED_FEATURES: 20873, ATTR_MEDIA_VOLUME_MUTED: False}, + base_attrs, ) await hass.async_block_till_done() acc = MediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, config) @@ -75,33 +76,35 @@ async def test_media_player_set_state(hass: HomeAssistant, hk_driver, events) -> assert acc.chars[FEATURE_PLAY_STOP].value is False assert acc.chars[FEATURE_TOGGLE_MUTE].value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True assert acc.chars[FEATURE_TOGGLE_MUTE].value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is True - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_ON_OFF].value is False - hass.states.async_set(entity_id, STATE_PLAYING) + hass.states.async_set(entity_id, STATE_PLAYING, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is True assert acc.chars[FEATURE_PLAY_STOP].value is True - hass.states.async_set(entity_id, STATE_PAUSED) + hass.states.async_set(entity_id, STATE_PAUSED, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_PAUSE].value is False - hass.states.async_set(entity_id, STATE_IDLE) + hass.states.async_set(entity_id, STATE_IDLE, base_attrs) await hass.async_block_till_done() assert acc.chars[FEATURE_PLAY_STOP].value is False @@ -180,15 +183,16 @@ async def test_media_player_television( # Supports 'select_source', 'volume_step', 'turn_on', 'turn_off', # 'volume_mute', 'volume_set', 'pause' + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 3469, + ATTR_MEDIA_VOLUME_MUTED: False, + ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], + } hass.states.async_set( entity_id, None, - { - ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, - ATTR_SUPPORTED_FEATURES: 3469, - ATTR_MEDIA_VOLUME_MUTED: False, - ATTR_INPUT_SOURCE_LIST: ["HDMI 1", "HDMI 2", "HDMI 3", "HDMI 4"], - }, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -203,32 +207,40 @@ async def test_media_player_television( assert acc.char_input_source.value == 0 assert acc.char_mute.value is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 assert acc.char_mute.value is True - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 2"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 2"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 2 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 5"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 5"} + ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 assert caplog.records[-2].levelname == "DEBUG" @@ -358,12 +370,15 @@ async def test_media_player_television_basic( ) -> None: """Test if basic television accessory and HA are updated accordingly.""" entity_id = "media_player.television" - + base_attrs = { + ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, + ATTR_SUPPORTED_FEATURES: 384, + } # Supports turn_on', 'turn_off' hass.states.async_set( entity_id, None, - {ATTR_DEVICE_CLASS: MediaPlayerDeviceClass.TV, ATTR_SUPPORTED_FEATURES: 384}, + base_attrs, ) await hass.async_block_till_done() acc = TelevisionMediaPlayer(hass, hk_driver, "MediaPlayer", entity_id, 2, None) @@ -374,15 +389,19 @@ async def test_media_player_television_basic( assert acc.chars_speaker == [] assert acc.support_select_source is False - hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_MEDIA_VOLUME_MUTED: True} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: "HDMI 3"}) + hass.states.async_set( + entity_id, STATE_ON, {**base_attrs, ATTR_INPUT_SOURCE: "HDMI 3"} + ) await hass.async_block_till_done() assert acc.char_active.value == 1 diff --git a/tests/components/homekit/test_type_remote.py b/tests/components/homekit/test_type_remote.py index 0c0a2266eb1..2e7a5174701 100644 --- a/tests/components/homekit/test_type_remote.py +++ b/tests/components/homekit/test_type_remote.py @@ -1,13 +1,14 @@ """Test different accessory types: Remotes.""" +from unittest.mock import patch + import pytest +from homeassistant.components.homekit.accessories import HomeDriver from homeassistant.components.homekit.const import ( ATTR_KEY_NAME, ATTR_VALUE, - DOMAIN as HOMEKIT_DOMAIN, EVENT_HOMEKIT_TV_REMOTE_KEY_PRESSED, KEY_ARROW_RIGHT, - SERVICE_HOMEKIT_RESET_ACCESSORY, ) from homeassistant.components.homekit.type_remotes import ActivityRemote from homeassistant.components.remote import ( @@ -30,18 +31,19 @@ from tests.common import async_mock_service async def test_activity_remote( - hass: HomeAssistant, hk_driver, events, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, hk_driver: HomeDriver, events, caplog: pytest.LogCaptureFixture ) -> None: """Test if remote accessory and HA are updated accordingly.""" entity_id = "remote.harmony" + base_attrs = { + ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, + ATTR_CURRENT_ACTIVITY: "Apple TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], + } hass.states.async_set( entity_id, None, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() acc = ActivityRemote(hass, hk_driver, "ActivityRemote", entity_id, 2, None) @@ -58,47 +60,31 @@ async def test_activity_remote( hass.states.async_set( entity_id, STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + base_attrs, ) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_OFF) + hass.states.async_set(entity_id, STATE_OFF, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 - hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_ON, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 1 - hass.states.async_set(entity_id, STATE_STANDBY) + hass.states.async_set(entity_id, STATE_STANDBY, base_attrs) await hass.async_block_till_done() assert acc.char_active.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 0 hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Apple TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV"], - }, + entity_id, STATE_ON, {**base_attrs, ATTR_CURRENT_ACTIVITY: "Apple TV"} ) await hass.async_block_till_done() assert acc.char_input_source.value == 1 @@ -154,21 +140,19 @@ async def test_activity_remote( assert len(events) == 1 assert events[0].data[ATTR_KEY_NAME] == KEY_ARROW_RIGHT - call_reset_accessory = async_mock_service( - hass, HOMEKIT_DOMAIN, SERVICE_HOMEKIT_RESET_ACCESSORY - ) - # A wild source appears - The accessory should rebuild itself - hass.states.async_set( - entity_id, - STATE_ON, - { - ATTR_SUPPORTED_FEATURES: RemoteEntityFeature.ACTIVITY, - ATTR_CURRENT_ACTIVITY: "Amazon TV", - ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], - }, - ) - await hass.async_block_till_done() - assert call_reset_accessory[0].data[ATTR_ENTITY_ID] == entity_id + # A wild source appears - The accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + STATE_ON, + { + **base_attrs, + ATTR_CURRENT_ACTIVITY: "Amazon TV", + ATTR_ACTIVITY_LIST: ["TV", "Apple TV", "Amazon TV"], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_activity_remote_bad_names( diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index c48ebb86ce3..d2f0d87c507 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,4 +1,6 @@ """Test different accessory types: Sensors.""" +from unittest.mock import patch + from homeassistant.components.binary_sensor import BinarySensorDeviceClass from homeassistant.components.homekit import get_accessory from homeassistant.components.homekit.const import ( @@ -71,11 +73,13 @@ async def test_temperature(hass: HomeAssistant, hk_driver) -> None: await hass.async_block_till_done() assert acc.char_temp.value == 0 - hass.states.async_set( - entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} - ) - await hass.async_block_till_done() - assert acc.char_temp.value == 24 + # The UOM changes, the accessory should reload itself + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, "75.2", {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.FAHRENHEIT} + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_humidity(hass: HomeAssistant, hk_driver) -> None: diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index da51efb43f2..a4ce765d795 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -79,21 +79,22 @@ from tests.common import async_mock_service async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -124,17 +125,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.2, ATTR_CURRENT_TEMPERATURE: 17.8, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -148,17 +142,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 23.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -172,17 +159,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -196,6 +176,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TEMPERATURE: 20.0, ATTR_CURRENT_TEMPERATURE: 19.0, ATTR_HVAC_ACTION: HVACAction.IDLE, @@ -211,7 +192,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, + {**base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0}, ) await hass.async_block_till_done() assert acc.char_target_temp.value == 22.0 @@ -224,17 +205,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -248,17 +222,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 25.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -272,17 +239,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -296,17 +256,10 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.FAN_ONLY, { + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.FAN, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -320,7 +273,7 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.DRY, { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE, + **base_attrs, ATTR_TEMPERATURE: 22.0, ATTR_CURRENT_TEMPERATURE: 22.0, ATTR_HVAC_ACTION: HVACAction.DRYING, @@ -419,23 +372,23 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -458,18 +411,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -484,18 +430,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -510,18 +449,11 @@ async def test_thermostat_auto(hass: HomeAssistant, hk_driver, events) -> None: entity_id, HVACMode.AUTO, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -575,23 +507,23 @@ async def test_thermostat_mode_and_temp_change( ) -> None: """Test if accessory where the mode and temp change in the same call.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [ + HVACMode.HEAT, + HVACMode.HEAT_COOL, + HVACMode.FAN_ONLY, + HVACMode.COOL, + HVACMode.OFF, + HVACMode.AUTO, + ], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -614,18 +546,11 @@ async def test_thermostat_mode_and_temp_change( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -688,9 +613,9 @@ async def test_thermostat_mode_and_temp_change( async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly with humidity.""" entity_id = "climate.test" - + base_attrs = {ATTR_SUPPORTED_FEATURES: 4} # support_auto = True - hass.states.async_set(entity_id, HVACMode.OFF, {ATTR_SUPPORTED_FEATURES: 4}) + hass.states.async_set(entity_id, HVACMode.OFF, base_attrs) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) hk_driver.add_accessory(acc) @@ -704,14 +629,18 @@ async def test_thermostat_humidity(hass: HomeAssistant, hk_driver, events) -> No assert acc.char_target_humidity.properties[PROP_MIN_VALUE] == DEFAULT_MIN_HUMIDITY hass.states.async_set( - entity_id, HVACMode.HEAT_COOL, {ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40} + entity_id, + HVACMode.HEAT_COOL, + {**base_attrs, ATTR_HUMIDITY: 65, ATTR_CURRENT_HUMIDITY: 40}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 40 assert acc.char_target_humidity.value == 65 hass.states.async_set( - entity_id, HVACMode.COOL, {ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70} + entity_id, + HVACMode.COOL, + {**base_attrs, ATTR_HUMIDITY: 35, ATTR_CURRENT_HUMIDITY: 70}, ) await hass.async_block_till_done() assert acc.char_current_humidity.value == 70 @@ -772,24 +701,24 @@ async def test_thermostat_humidity_with_target_humidity( async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> None: """Test if accessory and HA are updated accordingly.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: 4096, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.AUTO, + HVACMode.HEAT, + HVACMode.OFF, + ], + } # SUPPORT_ON_OFF = True hass.states.async_set( entity_id, HVACMode.HEAT, - { - ATTR_SUPPORTED_FEATURES: 4096, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -805,16 +734,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -825,16 +748,10 @@ async def test_thermostat_power_state(hass: HomeAssistant, hk_driver, events) -> entity_id, HVACMode.OFF, { + **base_attrs, ATTR_TEMPERATURE: 23.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT_COOL, - HVACMode.COOL, - HVACMode.AUTO, - HVACMode.HEAT, - HVACMode.OFF, - ], }, ) await hass.async_block_till_done() @@ -1566,12 +1483,15 @@ async def test_thermostat_without_target_temp_only_range( ) -> None: """Test a thermostat that only supports a range.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - {ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE}, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1594,19 +1514,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 22.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 18.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1621,19 +1533,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 24.0, ATTR_HVAC_ACTION: HVACAction.COOLING, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1648,19 +1552,11 @@ async def test_thermostat_without_target_temp_only_range( entity_id, HVACMode.COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 23.0, ATTR_TARGET_TEMP_LOW: 19.0, ATTR_CURRENT_TEMPERATURE: 21.0, ATTR_HVAC_ACTION: HVACAction.IDLE, - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [ - HVACMode.HEAT, - HVACMode.HEAT_COOL, - HVACMode.FAN_ONLY, - HVACMode.COOL, - HVACMode.OFF, - HVACMode.AUTO, - ], }, ) await hass.async_block_till_done() @@ -1925,16 +1821,17 @@ async def test_thermostat_with_no_modes_when_we_first_see( ) -> None: """Test if a thermostat that is not ready when we first see it.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.OFF, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -1955,24 +1852,22 @@ async def test_thermostat_with_no_modes_when_we_first_see( assert acc.char_target_heat_cool.value == 0 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload on modes changed out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.OFF, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_no_off_after_recheck( @@ -1981,15 +1876,16 @@ async def test_thermostat_with_no_off_after_recheck( """Test if a thermostat that is not ready when we first see it that actually does not have off.""" entity_id = "climate.test" + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [], + } # support_auto = True hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2010,24 +1906,22 @@ async def test_thermostat_with_no_off_after_recheck( assert acc.char_target_heat_cool.value == 2 - hass.states.async_set( - entity_id, - HVACMode.HEAT_COOL, - { - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], - }, - ) - await hass.async_block_till_done() - assert acc.char_heating_thresh_temp.value == 20.0 - assert acc.char_cooling_thresh_temp.value == 22.0 - assert acc.char_current_heat_cool.value == 1 - assert acc.char_target_heat_cool.value == 3 - assert acc.char_current_temp.value == 18.0 - assert acc.char_display_units.value == 0 + # Verify reload when modes change out from under us + with patch.object(acc, "async_reload") as mock_reload: + hass.states.async_set( + entity_id, + HVACMode.HEAT_COOL, + { + **base_attrs, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_HVAC_ACTION: HVACAction.HEATING, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + }, + ) + await hass.async_block_till_done() + assert mock_reload.called async def test_thermostat_with_temp_clamps( @@ -2035,17 +1929,17 @@ async def test_thermostat_with_temp_clamps( ) -> None: """Test that tempatures are clamped to valid values to prevent homekit crash.""" entity_id = "climate.test" - + base_attrs = { + ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, + ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], + ATTR_MAX_TEMP: 50, + ATTR_MIN_TEMP: 100, + } hass.states.async_set( entity_id, HVACMode.COOL, - { - ATTR_SUPPORTED_FEATURES: ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE, - ATTR_HVAC_MODES: [], - ATTR_MAX_TEMP: 50, - ATTR_MIN_TEMP: 100, - }, + base_attrs, ) await hass.async_block_till_done() acc = Thermostat(hass, hk_driver, "Climate", entity_id, 1, None) @@ -2064,17 +1958,17 @@ async def test_thermostat_with_temp_clamps( assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] == 100 assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.1 - assert acc.char_target_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 hass.states.async_set( entity_id, HVACMode.HEAT_COOL, { + **base_attrs, ATTR_TARGET_TEMP_HIGH: 822.0, ATTR_TARGET_TEMP_LOW: 20.0, ATTR_CURRENT_TEMPERATURE: 9918.0, ATTR_HVAC_ACTION: HVACAction.HEATING, - ATTR_HVAC_MODES: [HVACMode.HEAT_COOL, HVACMode.AUTO], }, ) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_type_triggers.py b/tests/components/homekit/test_type_triggers.py index 0374f3f1e94..84631646a6c 100644 --- a/tests/components/homekit/test_type_triggers.py +++ b/tests/components/homekit/test_type_triggers.py @@ -71,3 +71,4 @@ async def test_programmable_switch_button_fires_on_trigger( char = acc.get_characteristic(call.args[0]["aid"], call.args[0]["iid"]) assert char.display_name == CHAR_PROGRAMMABLE_SWITCH_EVENT await acc.stop() + await hass.async_block_till_done() From 4b9296f4f14d2fcf6f32da0db4ea92061547e46f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 10 Oct 2023 19:58:43 +0200 Subject: [PATCH 340/968] Code quality issue met integration (#101768) * Fix unreachable code for met integration * Make code better readable --- homeassistant/components/met/weather.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a1cc1ade8e1..def06634f42 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -55,10 +55,10 @@ async def async_setup_entry( is_metric = hass.config.units is METRIC_SYSTEM if config_entry.data.get(CONF_TRACK_HOME, False): name = hass.config.location_name - elif (name := config_entry.data.get(CONF_NAME)) and name is None: - name = DEFAULT_NAME - elif TYPE_CHECKING: - assert isinstance(name, str) + else: + name = config_entry.data.get(CONF_NAME, DEFAULT_NAME) + if TYPE_CHECKING: + assert isinstance(name, str) entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] From 71ddb282d265f7ec7e822fb708aa3cc68a8747c8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 10 Oct 2023 20:02:14 +0200 Subject: [PATCH 341/968] Address late review from wallbox coordinator move (#101771) Address late wallbox review comment --- homeassistant/components/wallbox/const.py | 34 ------------------- .../components/wallbox/coordinator.py | 34 ++++++++++++++++++- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index cd3f8a764d0..5db279650c4 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -56,37 +56,3 @@ class ChargerStatus(StrEnum): WAITING_MID_SAFETY = "Waiting MID safety margin exceeded" WAITING_IN_QUEUE_ECO_SMART = "Waiting in queue by Eco-Smart" UNKNOWN = "Unknown" - - -# Translation of StatusId based on Wallbox portal code: -# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js -CHARGER_STATUS: dict[int, ChargerStatus] = { - 0: ChargerStatus.DISCONNECTED, - 14: ChargerStatus.ERROR, - 15: ChargerStatus.ERROR, - 161: ChargerStatus.READY, - 162: ChargerStatus.READY, - 163: ChargerStatus.DISCONNECTED, - 164: ChargerStatus.WAITING, - 165: ChargerStatus.LOCKED, - 166: ChargerStatus.UPDATING, - 177: ChargerStatus.SCHEDULED, - 178: ChargerStatus.PAUSED, - 179: ChargerStatus.SCHEDULED, - 180: ChargerStatus.WAITING_FOR_CAR, - 181: ChargerStatus.WAITING_FOR_CAR, - 182: ChargerStatus.PAUSED, - 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, - 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, - 187: ChargerStatus.WAITING_MID_FAILED, - 188: ChargerStatus.WAITING_MID_SAFETY, - 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, - 193: ChargerStatus.CHARGING, - 194: ChargerStatus.CHARGING, - 195: ChargerStatus.CHARGING, - 196: ChargerStatus.DISCHARGING, - 209: ChargerStatus.LOCKED, - 210: ChargerStatus.LOCKED_CAR_CONNECTED, -} diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index eaa425a53ef..fe8dd2469c3 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -19,7 +19,6 @@ from .const import ( CHARGER_ENERGY_PRICE_KEY, CHARGER_LOCKED_UNLOCKED_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, - CHARGER_STATUS, CHARGER_STATUS_DESCRIPTION_KEY, CHARGER_STATUS_ID_KEY, CODE_KEY, @@ -30,6 +29,39 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +# Translation of StatusId based on Wallbox portal code: +# https://my.wallbox.com/src/utilities/charger/chargerStatuses.js +CHARGER_STATUS: dict[int, ChargerStatus] = { + 0: ChargerStatus.DISCONNECTED, + 14: ChargerStatus.ERROR, + 15: ChargerStatus.ERROR, + 161: ChargerStatus.READY, + 162: ChargerStatus.READY, + 163: ChargerStatus.DISCONNECTED, + 164: ChargerStatus.WAITING, + 165: ChargerStatus.LOCKED, + 166: ChargerStatus.UPDATING, + 177: ChargerStatus.SCHEDULED, + 178: ChargerStatus.PAUSED, + 179: ChargerStatus.SCHEDULED, + 180: ChargerStatus.WAITING_FOR_CAR, + 181: ChargerStatus.WAITING_FOR_CAR, + 182: ChargerStatus.PAUSED, + 183: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 184: ChargerStatus.WAITING_IN_QUEUE_POWER_SHARING, + 185: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 186: ChargerStatus.WAITING_IN_QUEUE_POWER_BOOST, + 187: ChargerStatus.WAITING_MID_FAILED, + 188: ChargerStatus.WAITING_MID_SAFETY, + 189: ChargerStatus.WAITING_IN_QUEUE_ECO_SMART, + 193: ChargerStatus.CHARGING, + 194: ChargerStatus.CHARGING, + 195: ChargerStatus.CHARGING, + 196: ChargerStatus.DISCHARGING, + 209: ChargerStatus.LOCKED, + 210: ChargerStatus.LOCKED_CAR_CONNECTED, +} + class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Wallbox Coordinator class.""" From 5290396731b3d6c227f34b0e0dca1408db90d7e9 Mon Sep 17 00:00:00 2001 From: Caius-Bonus <123886836+Caius-Bonus@users.noreply.github.com> Date: Tue, 10 Oct 2023 20:23:03 +0200 Subject: [PATCH 342/968] ZHA Component: Correct AttributeUpdated signal in Thermostat climate entity, ThermostatClusterHandler and ThermostatHVACAction sensor entity (#101725) * initial * change other Thermostat climate entities * remove AttributeUpdateRecord --- homeassistant/components/zha/climate.py | 60 +++++++++---------- .../zha/core/cluster_handlers/hvac.py | 6 +- homeassistant/components/zha/sensor.py | 5 -- 3 files changed, 33 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 5cbe2684ab4..1151d2fe59d 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -367,10 +367,10 @@ class Thermostat(ZhaEntity, ClimateEntity): self._thrm, SIGNAL_ATTR_UPDATED, self.async_attribute_updated ) - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" if ( - record.attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) + attr_name in (ATTR_OCCP_COOL_SETPT, ATTR_OCCP_HEAT_SETPT) and self.preset_mode == PRESET_AWAY ): # occupancy attribute is an unreportable attribute, but if we get @@ -379,7 +379,7 @@ class Thermostat(ZhaEntity, ClimateEntity): if await self._thrm.get_occupancy() is True: self._preset = PRESET_NONE - self.debug("Attribute '%s' = %s update", record.attr_name, record.value) + self.debug("Attribute '%s' = %s update", attr_name, value) self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode: str) -> None: @@ -609,24 +609,24 @@ class MoesThermostat(Thermostat): """Return only the heat mode, because the device can't be turned off.""" return [HVACMode.HEAT] - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_AWAY - if record.value == 1: + if value == 1: self._preset = PRESET_SCHEDULE - if record.value == 2: + if value == 2: self._preset = PRESET_NONE - if record.value == 3: + if value == 3: self._preset = PRESET_COMFORT - if record.value == 4: + if value == 4: self._preset = PRESET_ECO - if record.value == 5: + if value == 5: self._preset = PRESET_BOOST - if record.value == 6: + if value == 6: self._preset = PRESET_COMPLEX - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" @@ -688,22 +688,22 @@ class BecaThermostat(Thermostat): """Return only the heat mode, because the device can't be turned off.""" return [HVACMode.HEAT] - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_AWAY - if record.value == 1: + if value == 1: self._preset = PRESET_SCHEDULE - if record.value == 2: + if value == 2: self._preset = PRESET_NONE - if record.value == 4: + if value == 4: self._preset = PRESET_ECO - if record.value == 5: + if value == 5: self._preset = PRESET_BOOST - if record.value == 7: + if value == 7: self._preset = PRESET_TEMP_MANUAL - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" @@ -783,20 +783,20 @@ class ZONNSMARTThermostat(Thermostat): ] self._supported_flags |= ClimateEntityFeature.PRESET_MODE - async def async_attribute_updated(self, record): + async def async_attribute_updated(self, attr_id, attr_name, value): """Handle attribute update from device.""" - if record.attr_name == "operation_preset": - if record.value == 0: + if attr_name == "operation_preset": + if value == 0: self._preset = PRESET_SCHEDULE - if record.value == 1: + if value == 1: self._preset = PRESET_NONE - if record.value == 2: + if value == 2: self._preset = self.PRESET_HOLIDAY - if record.value == 3: + if value == 3: self._preset = self.PRESET_HOLIDAY - if record.value == 4: + if value == 4: self._preset = self.PRESET_FROST - await super().async_attribute_updated(record) + await super().async_attribute_updated(attr_id, attr_name, value) async def async_preset_handler(self, preset: str, enable: bool = False) -> None: """Set the preset mode.""" diff --git a/homeassistant/components/zha/core/cluster_handlers/hvac.py b/homeassistant/components/zha/core/cluster_handlers/hvac.py index 15050ce67b1..dad3ee5eb4d 100644 --- a/homeassistant/components/zha/core/cluster_handlers/hvac.py +++ b/homeassistant/components/zha/core/cluster_handlers/hvac.py @@ -5,7 +5,6 @@ https://home-assistant.io/integrations/zha/ """ from __future__ import annotations -from collections import namedtuple from typing import Any from zigpy.zcl.clusters import hvac @@ -21,7 +20,6 @@ from ..const import ( ) from . import AttrReportConfig, ClusterHandler -AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) REPORT_CONFIG_CLIMATE_DEMAND = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 5) REPORT_CONFIG_CLIMATE_DISCRETE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 1) @@ -235,7 +233,9 @@ class ThermostatClusterHandler(ClusterHandler): ) self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - AttributeUpdateRecord(attrid, attr_name, value), + attrid, + attr_name, + value, ) async def async_set_operation_mode(self, mode) -> bool: diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 1e166675b5b..b733e5cc3cf 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -854,11 +854,6 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): return HVACAction.IDLE return HVACAction.OFF - @callback - def async_set_state(self, *args, **kwargs) -> None: - """Handle state update from cluster handler.""" - self.async_write_ha_state() - @MULTI_MATCH( cluster_handler_names={CLUSTER_HANDLER_THERMOSTAT}, From 1a8684e314afdd4a24b039ee924e389c715933f6 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 10 Oct 2023 12:43:40 -0600 Subject: [PATCH 343/968] Bump pyweatherflowudp to 1.4.5 (#101770) --- homeassistant/components/weatherflow/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/weatherflow/manifest.json b/homeassistant/components/weatherflow/manifest.json index 3c34250652d..704be808867 100644 --- a/homeassistant/components/weatherflow/manifest.json +++ b/homeassistant/components/weatherflow/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyweatherflowudp"], - "requirements": ["pyweatherflowudp==1.4.3"] + "requirements": ["pyweatherflowudp==1.4.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index d0720fb7b09..ebf67ed8f61 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2249,7 +2249,7 @@ pyvolumio==0.1.5 pywaze==0.5.1 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.3 +pyweatherflowudp==1.4.5 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a375f861a5..324b5e3857a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1675,7 +1675,7 @@ pyvolumio==0.1.5 pywaze==0.5.1 # homeassistant.components.weatherflow -pyweatherflowudp==1.4.3 +pyweatherflowudp==1.4.5 # homeassistant.components.html5 pywebpush==1.9.2 From c76ce7682453b019e66dd98538e23b06e4b52ce6 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 10 Oct 2023 21:07:50 +0200 Subject: [PATCH 344/968] Bump pyOverkiz to 1.12.1 in Overkiz integration (#101765) --- homeassistant/components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 4e1fdee989a..3b3afddc489 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -13,7 +13,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"], - "requirements": ["pyoverkiz==1.11.0"], + "requirements": ["pyoverkiz==1.12.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ebf67ed8f61..a18c5f19d75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1925,7 +1925,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.11.0 +pyoverkiz==1.12.1 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 324b5e3857a..84419d16cc1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1450,7 +1450,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.11.0 +pyoverkiz==1.12.1 # homeassistant.components.openweathermap pyowm==3.2.0 From b932c67eb775102b6da56a5af62ae4a4dd682f1c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 10 Oct 2023 21:08:54 +0200 Subject: [PATCH 345/968] Delete optional schema keys, when they are not present (#101755) Co-authored-by: Erik Montnemery --- .../helpers/schema_config_entry_flow.py | 30 +++++++- .../helpers/test_schema_config_entry_flow.py | 77 +++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 20a5d8de5a8..dcf7f07bf6b 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -171,13 +171,37 @@ class SchemaCommonFlowHandler: if user_input is not None: # User input was validated successfully, update options - self._options.update(user_input) + self._update_and_remove_omitted_optional_keys( + self._options, user_input, data_schema + ) if user_input is not None or form_step.schema is None: return await self._show_next_step_or_create_entry(form_step) return await self._show_next_step(step_id) + def _update_and_remove_omitted_optional_keys( + self, + values: dict[str, Any], + user_input: dict[str, Any], + data_schema: vol.Schema | None, + ) -> None: + values.update(user_input) + if data_schema and data_schema.schema: + for key in data_schema.schema: + if ( + isinstance(key, vol.Optional) + and key not in user_input + and not ( + # don't remove advanced keys, if they are hidden + key.description + and key.description.get("advanced") + and not self._handler.show_advanced_options + ) + ): + # Key not present, delete keys old value (if present) too + values.pop(key, None) + async def _show_next_step_or_create_entry( self, form_step: SchemaFlowFormStep ) -> FlowResult: @@ -221,7 +245,9 @@ class SchemaCommonFlowHandler: if user_input: # We don't want to mutate the existing options suggested_values = copy.deepcopy(suggested_values) - suggested_values.update(user_input) + self._update_and_remove_omitted_optional_keys( + suggested_values, user_input, await self._get_schema(form_step) + ) if data_schema.schema: # Make a copy of the schema with suggested values set to saved options diff --git a/tests/helpers/test_schema_config_entry_flow.py b/tests/helpers/test_schema_config_entry_flow.py index 0bc8e0f1ff3..7954b63b241 100644 --- a/tests/helpers/test_schema_config_entry_flow.py +++ b/tests/helpers/test_schema_config_entry_flow.py @@ -671,3 +671,80 @@ async def test_options_flow_state(hass: HomeAssistant) -> None: "idx_from_flow_state": "blublu", "option1": "blabla", } + + +async def test_options_flow_omit_optional_keys( + hass: HomeAssistant, manager: data_entry_flow.FlowManager +) -> None: + """Test handling of advanced options in options flow.""" + manager.hass = hass + + OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional("optional_no_default"): str, + vol.Optional("optional_default", default="a very reasonable default"): str, + vol.Optional("advanced_no_default", description={"advanced": True}): str, + vol.Optional( + "advanced_default", + default="a very reasonable default", + description={"advanced": True}, + ): str, + } + ) + + OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(OPTIONS_SCHEMA) + } + + class TestFlow(MockSchemaConfigFlowHandler, domain="test"): + config_flow = {} + options_flow = OPTIONS_FLOW + + mock_integration(hass, MockModule("test")) + mock_entity_platform(hass, "config_flow.test", None) + config_entry = MockConfigEntry( + data={}, + domain="test", + options={ + "optional_no_default": "abc123", + "optional_default": "not default", + "advanced_no_default": "abc123", + "advanced_default": "not default", + }, + ) + config_entry.add_to_hass(hass) + + # Start flow in basic mode + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert list(result["data_schema"].schema.keys()) == [ + "optional_no_default", + "optional_default", + ] + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "advanced_default": "not default", + "advanced_no_default": "abc123", + "optional_default": "a very reasonable default", + } + + # Start flow in advanced mode + result = await hass.config_entries.options.async_init( + config_entry.entry_id, context={"show_advanced_options": True} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert list(result["data_schema"].schema.keys()) == [ + "optional_no_default", + "optional_default", + "advanced_no_default", + "advanced_default", + ] + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + "advanced_default": "a very reasonable default", + "optional_default": "a very reasonable default", + } From 6c65db20368b15dd70b2fa54f33a74ae783c2985 Mon Sep 17 00:00:00 2001 From: Sheldon Ip <4224778+sheldonip@users.noreply.github.com> Date: Tue, 10 Oct 2023 20:12:43 +0100 Subject: [PATCH 346/968] Add rising sensor to sun (#93276) --- homeassistant/components/sun/sensor.py | 8 +++++ homeassistant/components/sun/strings.json | 3 +- tests/components/sun/test_sensor.py | 40 +++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index f83564bbac3..0f867f9b7c4 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -108,6 +108,14 @@ SENSOR_TYPES: tuple[SunSensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, signal=SIGNAL_POSITION_CHANGED, ), + SunSensorEntityDescription( + key="solar_rising", + translation_key="solar_rising", + icon="mdi:sun-clock", + value_fn=lambda data: data.rising, + entity_registry_enabled_default=False, + signal=SIGNAL_EVENTS_CHANGED, + ), ) diff --git a/homeassistant/components/sun/strings.json b/homeassistant/components/sun/strings.json index 3d0374f1de0..eb538eedf09 100644 --- a/homeassistant/components/sun/strings.json +++ b/homeassistant/components/sun/strings.json @@ -28,7 +28,8 @@ "next_rising": { "name": "Next rising" }, "next_setting": { "name": "Next setting" }, "solar_azimuth": { "name": "Solar azimuth" }, - "solar_elevation": { "name": "Solar elevation" } + "solar_elevation": { "name": "Solar elevation" }, + "solar_rising": { "name": "Solar rising" } } } } diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 6559cc3f7e9..b1fb0d2facd 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -138,3 +138,43 @@ async def test_setting_rising( assert ( solar_azimuth_state.state != hass.states.get("sensor.sun_solar_azimuth").state ) + + entity = entity_reg.async_get("sensor.sun_next_dusk") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dusk" + + entity = entity_reg.async_get("sensor.sun_next_midnight") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_midnight" + + entity = entity_reg.async_get("sensor.sun_next_noon") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_noon" + + entity = entity_reg.async_get("sensor.sun_next_rising") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_rising" + + entity = entity_reg.async_get("sensor.sun_next_setting") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-next_setting" + + entity = entity_reg.async_get("sensor.sun_solar_elevation") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_elevation" + + entity = entity_reg.async_get("sensor.sun_solar_azimuth") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_azimuth" + + entity = entity_reg.async_get("sensor.sun_solar_rising") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC + assert entity.unique_id == f"{entry_ids[0].entry_id}-solar_rising" From 265f6653c3c941750913353e3c9be047c74c7a75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Oct 2023 09:14:37 -1000 Subject: [PATCH 347/968] Refactor homekit to use a dataclass for entry data (#101738) --- homeassistant/components/homekit/__init__.py | 38 ++++++++++--------- homeassistant/components/homekit/const.py | 5 +-- .../components/homekit/diagnostics.py | 7 ++-- homeassistant/components/homekit/models.py | 15 ++++++++ homeassistant/components/homekit/util.py | 9 +++-- tests/components/homekit/test_homekit.py | 8 ++-- tests/components/homekit/test_util.py | 8 ++-- 7 files changed, 52 insertions(+), 38 deletions(-) create mode 100644 homeassistant/components/homekit/models.py diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c3b7bf5d2e6..0920530524d 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -112,18 +112,16 @@ from .const import ( DEFAULT_HOMEKIT_MODE, DEFAULT_PORT, DOMAIN, - HOMEKIT, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODES, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, MANUFACTURER, - PERSIST_LOCK, + PERSIST_LOCK_DATA, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_UNPAIR, SHUTDOWN_TIMEOUT, ) from .iidmanager import AccessoryIIDStorage +from .models import HomeKitEntryData from .type_triggers import DeviceTriggerAccessory from .util import ( accessory_friendly_name, @@ -205,11 +203,8 @@ UNPAIR_SERVICE_SCHEMA = vol.All( def _async_all_homekit_instances(hass: HomeAssistant) -> list[HomeKit]: """All active HomeKit instances.""" - return [ - data[HOMEKIT] - for data in hass.data[DOMAIN].values() - if isinstance(data, dict) and HOMEKIT in data - ] + domain_data: dict[str, HomeKitEntryData] = hass.data[DOMAIN] + return [data.homekit for data in domain_data.values()] def _async_get_imported_entries_indices( @@ -231,7 +226,8 @@ def _async_get_imported_entries_indices( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HomeKit from yaml.""" - hass.data.setdefault(DOMAIN, {})[PERSIST_LOCK] = asyncio.Lock() + hass.data[DOMAIN] = {} + hass.data[PERSIST_LOCK_DATA] = asyncio.Lock() # Initialize the loader before loading entries to ensure # there is no race where multiple entries try to load it @@ -352,7 +348,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, homekit.async_stop) ) - hass.data[DOMAIN][entry.entry_id] = {HOMEKIT: homekit} + entry_data = HomeKitEntryData( + homekit=homekit, pairing_qr=None, pairing_qr_secret=None + ) + hass.data[DOMAIN][entry.entry_id] = entry_data if hass.state == CoreState.running: await homekit.async_start() @@ -372,7 +371,8 @@ async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> Non async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" async_dismiss_setup_message(hass, entry.entry_id) - homekit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + homekit = entry_data.homekit if homekit.status == STATUS_RUNNING: await homekit.async_stop() @@ -849,7 +849,7 @@ class HomeKit: self._async_register_bridge() _LOGGER.debug("Driver start for %s", self._name) await self.driver.async_start() - async with self.hass.data[DOMAIN][PERSIST_LOCK]: + async with self.hass.data[PERSIST_LOCK_DATA]: await self.hass.async_add_executor_job(self.driver.persist) self.status = STATUS_RUNNING @@ -1162,14 +1162,16 @@ class HomeKitPairingQRView(HomeAssistantView): if not request.query_string: raise Unauthorized() entry_id, secret = request.query_string.split("-") - + hass: HomeAssistant = request.app["hass"] + domain_data: dict[str, HomeKitEntryData] = hass.data[DOMAIN] if ( - entry_id not in request.app["hass"].data[DOMAIN] - or secret - != request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] + not (entry_data := domain_data.get(entry_id)) + or not secret + or not entry_data.pairing_qr_secret + or secret != entry_data.pairing_qr_secret ): raise Unauthorized() return web.Response( - body=request.app["hass"].data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR], + body=entry_data.pairing_qr, content_type="image/svg+xml", ) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index bb5ae1ffd1c..5a7ee1d9576 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -6,13 +6,10 @@ from homeassistant.const import CONF_DEVICES DEBOUNCE_TIMEOUT = 0.5 DEVICE_PRECISION_LEEWAY = 6 DOMAIN = "homekit" +PERSIST_LOCK_DATA = f"{DOMAIN}_persist_lock" HOMEKIT_FILE = ".homekit.state" -HOMEKIT_PAIRING_QR = "homekit-pairing-qr" -HOMEKIT_PAIRING_QR_SECRET = "homekit-pairing-qr-secret" -HOMEKIT = "homekit" SHUTDOWN_TIMEOUT = 30 CONF_ENTRY_INDEX = "index" -PERSIST_LOCK = "persist_lock" # ### Codecs #### VIDEO_CODEC_COPY = "copy" diff --git a/homeassistant/components/homekit/diagnostics.py b/homeassistant/components/homekit/diagnostics.py index f27171e6eae..347a3df0dd4 100644 --- a/homeassistant/components/homekit/diagnostics.py +++ b/homeassistant/components/homekit/diagnostics.py @@ -10,9 +10,9 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import HomeKit from .accessories import HomeAccessory, HomeBridge -from .const import DOMAIN, HOMEKIT +from .const import DOMAIN +from .models import HomeKitEntryData TO_REDACT = {"access_token", "entity_picture"} @@ -21,7 +21,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - homekit: HomeKit = hass.data[DOMAIN][entry.entry_id][HOMEKIT] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + homekit = entry_data.homekit data: dict[str, Any] = { "status": homekit.status, "config-entry": { diff --git a/homeassistant/components/homekit/models.py b/homeassistant/components/homekit/models.py new file mode 100644 index 00000000000..e96af00fead --- /dev/null +++ b/homeassistant/components/homekit/models.py @@ -0,0 +1,15 @@ +"""Models for the HomeKit component.""" +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from . import HomeKit + + +@dataclass +class HomeKitEntryData: + """Class to hold HomeKit data.""" + + homekit: "HomeKit" + pairing_qr: bytes | None = None + pairing_qr_secret: str | None = None diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 151b97f2cda..8a51f35564e 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -86,8 +86,6 @@ from .const import ( FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, MAX_NAME_LENGTH, TYPE_FAUCET, TYPE_OUTLET, @@ -100,6 +98,7 @@ from .const import ( VIDEO_CODEC_H264_V4L2M2M, VIDEO_CODEC_LIBX264, ) +from .models import HomeKitEntryData _LOGGER = logging.getLogger(__name__) @@ -352,8 +351,10 @@ def async_show_setup_message( url.svg(buffer, scale=5, module_color="#000", background="#FFF") pairing_secret = secrets.token_hex(32) - hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR] = buffer.getvalue() - hass.data[DOMAIN][entry_id][HOMEKIT_PAIRING_QR_SECRET] = pairing_secret + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry_id] + + entry_data.pairing_qr = buffer.getvalue() + entry_data.pairing_qr_secret = pairing_secret message = ( f"To set up {bridge_name} in the Home App, " diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index ebb710561d9..5c517ac9cb9 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -28,12 +28,12 @@ from homeassistant.components.homekit.const import ( CONF_ADVERTISE_IP, DEFAULT_PORT, DOMAIN, - HOMEKIT, HOMEKIT_MODE_ACCESSORY, HOMEKIT_MODE_BRIDGE, SERVICE_HOMEKIT_RESET_ACCESSORY, SERVICE_HOMEKIT_UNPAIR, ) +from homeassistant.components.homekit.models import HomeKitEntryData from homeassistant.components.homekit.type_triggers import DeviceTriggerAccessory from homeassistant.components.homekit.util import get_persist_fullpath_for_entry_id from homeassistant.components.light import ( @@ -1799,10 +1799,8 @@ async def test_homekit_uses_system_zeroconf( entry.add_to_hass(hass) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert ( - hass.data[DOMAIN][entry.entry_id][HOMEKIT].driver.advertiser - == system_async_zc - ) + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + assert entry_data.homekit.driver.advertiser == system_async_zc assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 0046f90b284..60ee2a4d8e8 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -14,8 +14,6 @@ from homeassistant.components.homekit.const import ( DOMAIN, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, - HOMEKIT_PAIRING_QR, - HOMEKIT_PAIRING_QR_SECRET, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, @@ -23,6 +21,7 @@ from homeassistant.components.homekit.const import ( TYPE_SWITCH, TYPE_VALVE, ) +from homeassistant.components.homekit.models import HomeKitEntryData from homeassistant.components.homekit.util import ( accessory_friendly_name, async_dismiss_setup_message, @@ -251,8 +250,9 @@ async def test_async_show_setup_msg( hass, entry.entry_id, "bridge_name", pincode, "X-HM://0" ) await hass.async_block_till_done() - assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR_SECRET] - assert hass.data[DOMAIN][entry.entry_id][HOMEKIT_PAIRING_QR] + entry_data: HomeKitEntryData = hass.data[DOMAIN][entry.entry_id] + assert entry_data.pairing_qr_secret + assert entry_data.pairing_qr assert len(mock_create.mock_calls) == 1 assert mock_create.mock_calls[0][1][3] == entry.entry_id From 535e2b81cefd062861d7e809bb55ffe7eed08a2b Mon Sep 17 00:00:00 2001 From: Hessel Date: Tue, 10 Oct 2023 21:23:02 +0200 Subject: [PATCH 348/968] Change BiDirectional Prefix (#101764) --- homeassistant/components/wallbox/const.py | 2 +- homeassistant/components/wallbox/number.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 5db279650c4..6caa3c070c8 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -4,7 +4,7 @@ from enum import StrEnum DOMAIN = "wallbox" UPDATE_INTERVAL = 30 -BIDIRECTIONAL_MODEL_PREFIXES = ["QSX"] +BIDIRECTIONAL_MODEL_PREFIXES = ["QS"] CODE_KEY = "code" CONF_STATION = "station" diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index d53a842b916..13938626336 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -80,7 +80,7 @@ class WallboxNumber(WallboxEntity, NumberEntity): self._coordinator = coordinator self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" self._is_bidirectional = ( - coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:3] + coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] in BIDIRECTIONAL_MODEL_PREFIXES ) From ba91aaa28d9142dbe013a4a47bc5841bc989d66a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 10 Oct 2023 21:34:49 +0200 Subject: [PATCH 349/968] Add support for Python 3.12 (#101651) --- .github/workflows/ci.yaml | 2 +- .github/workflows/wheels.yml | 6 +- .../components/apache_kafka/__init__.py | 11 ++- homeassistant/components/brother/__init__.py | 15 ++- homeassistant/components/brother/utils.py | 10 +- .../cisco_webex_teams/manifest.json | 2 +- .../components/cisco_webex_teams/notify.py | 11 ++- .../components/metoffice/__init__.py | 12 ++- homeassistant/components/metoffice/data.py | 10 +- homeassistant/components/metoffice/helpers.py | 9 +- .../components/metoffice/manifest.json | 2 +- homeassistant/components/profiler/__init__.py | 5 + .../components/profiler/manifest.json | 6 +- .../components/python_script/manifest.json | 5 +- .../components/snmp/device_tracker.py | 13 ++- homeassistant/components/snmp/sensor.py | 34 ++++--- homeassistant/components/snmp/switch.py | 91 ++++++++++--------- homeassistant/components/vulcan/__init__.py | 15 ++- pyproject.toml | 57 ++++++++++++ requirements_all.txt | 11 ++- requirements_test_all.txt | 9 +- tests/components/apache_kafka/conftest.py | 5 + tests/components/brother/__init__.py | 5 +- tests/components/brother/conftest.py | 4 + tests/components/metoffice/conftest.py | 7 +- tests/components/profiler/test_init.py | 22 +++++ tests/components/snmp/conftest.py | 5 + tests/components/vulcan/conftest.py | 5 + 28 files changed, 296 insertions(+), 93 deletions(-) create mode 100644 tests/components/apache_kafka/conftest.py create mode 100644 tests/components/snmp/conftest.py create mode 100644 tests/components/vulcan/conftest.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4a7ad03f218..ba4a37fda14 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -39,7 +39,7 @@ env: BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.11" DEFAULT_PYTHON: "3.11" - ALL_PYTHON_VERSIONS: "['3.11']" + ALL_PYTHON_VERSIONS: "['3.11', '3.12']" # 10.3 is the oldest supported version # - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022) # 10.6 is the current long-term-support diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 83f81b0cd4a..f3bab1872c7 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -82,7 +82,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311"] + abi: ["cp311", "cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository @@ -112,7 +112,7 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" - integrations_cp311: + integrations: name: Build wheels ${{ matrix.abi }} for ${{ matrix.arch }} if: github.repository_owner == 'home-assistant' needs: init @@ -120,7 +120,7 @@ jobs: strategy: fail-fast: false matrix: - abi: ["cp311"] + abi: ["cp311", "cp312"] arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository diff --git a/homeassistant/components/apache_kafka/__init__.py b/homeassistant/components/apache_kafka/__init__.py index 38a70b450ab..c974735791e 100644 --- a/homeassistant/components/apache_kafka/__init__.py +++ b/homeassistant/components/apache_kafka/__init__.py @@ -1,8 +1,8 @@ """Support for Apache Kafka.""" from datetime import datetime import json +import sys -from aiokafka import AIOKafkaProducer import voluptuous as vol from homeassistant.const import ( @@ -16,11 +16,16 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.helpers.typing import ConfigType from homeassistant.util import ssl as ssl_util +if sys.version_info < (3, 12): + from aiokafka import AIOKafkaProducer + + DOMAIN = "apache_kafka" CONF_FILTER = "filter" @@ -49,6 +54,10 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Activate the Apache Kafka integration.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Apache Kafka is not supported on Python 3.12. Please use Python 3.11." + ) conf = config[DOMAIN] kafka = hass.data[DOMAIN] = KafkaManager( diff --git a/homeassistant/components/brother/__init__.py b/homeassistant/components/brother/__init__.py index 27ac97a27dc..0f8f94c73c4 100644 --- a/homeassistant/components/brother/__init__.py +++ b/homeassistant/components/brother/__init__.py @@ -4,18 +4,23 @@ from __future__ import annotations from asyncio import timeout from datetime import timedelta import logging - -from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError +import sys +from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DATA_CONFIG_ENTRY, DOMAIN, SNMP from .utils import get_snmp_engine +if sys.version_info < (3, 12): + from brother import Brother, BrotherSensors, SnmpError, UnsupportedModelError +else: + BrotherSensors = Any + PLATFORMS = [Platform.SENSOR] SCAN_INTERVAL = timedelta(seconds=30) @@ -25,6 +30,10 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Brother from a config entry.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Brother Printer is not supported on Python 3.12. Please use Python 3.11." + ) host = entry.data[CONF_HOST] printer_type = entry.data[CONF_TYPE] diff --git a/homeassistant/components/brother/utils.py b/homeassistant/components/brother/utils.py index e421be52154..cd472b9b754 100644 --- a/homeassistant/components/brother/utils.py +++ b/homeassistant/components/brother/utils.py @@ -1,8 +1,8 @@ """Brother helpers functions.""" -import logging +from __future__ import annotations -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio.cmdgen import lcd +import logging +import sys from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback @@ -10,6 +10,10 @@ from homeassistant.helpers import singleton from .const import DOMAIN, SNMP +if sys.version_info < (3, 12): + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio.cmdgen import lcd + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json index 4fe333f40a5..6f4e1ead956 100644 --- a/homeassistant/components/cisco_webex_teams/manifest.json +++ b/homeassistant/components/cisco_webex_teams/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/cisco_webex_teams", "iot_class": "cloud_push", "loggers": ["webexteamssdk"], - "requirements": ["webexteamssdk==1.1.1"] + "requirements": ["webexteamssdk==1.1.1;python_version<'3.12'"] } diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py index be8710c7096..d2c75d78390 100644 --- a/homeassistant/components/cisco_webex_teams/notify.py +++ b/homeassistant/components/cisco_webex_teams/notify.py @@ -2,9 +2,9 @@ from __future__ import annotations import logging +import sys import voluptuous as vol -from webexteamssdk import ApiError, WebexTeamsAPI, exceptions from homeassistant.components.notify import ( ATTR_TITLE, @@ -13,9 +13,14 @@ from homeassistant.components.notify import ( ) from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +if sys.version_info < (3, 12): + from webexteamssdk import ApiError, WebexTeamsAPI, exceptions + + _LOGGER = logging.getLogger(__name__) CONF_ROOM_ID = "room_id" @@ -31,6 +36,10 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CiscoWebexTeamsNotificationService | None: """Get the CiscoWebexTeams notification service.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Cisco Webex Teams is not supported on Python 3.12. Please use Python 3.11." + ) client = WebexTeamsAPI(access_token=config[CONF_TOKEN]) try: diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py index a658de9a024..e00215f6073 100644 --- a/homeassistant/components/metoffice/__init__.py +++ b/homeassistant/components/metoffice/__init__.py @@ -4,10 +4,9 @@ from __future__ import annotations import asyncio import logging import re +import sys from typing import Any -import datapoint - from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, @@ -17,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator @@ -35,6 +34,9 @@ from .const import ( from .data import MetOfficeData from .helpers import fetch_data, fetch_site +if sys.version_info < (3, 12): + import datapoint + _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR, Platform.WEATHER] @@ -42,6 +44,10 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Met Office entry.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Met Office is not supported on Python 3.12. Please use Python 3.11." + ) latitude = entry.data[CONF_LATITUDE] longitude = entry.data[CONF_LONGITUDE] diff --git a/homeassistant/components/metoffice/data.py b/homeassistant/components/metoffice/data.py index 4b2741ce0fb..8512dd4c7a6 100644 --- a/homeassistant/components/metoffice/data.py +++ b/homeassistant/components/metoffice/data.py @@ -1,11 +1,13 @@ """Common Met Office Data class used by both sensor and entity.""" - +from __future__ import annotations from dataclasses import dataclass +import sys -from datapoint.Forecast import Forecast -from datapoint.Site import Site -from datapoint.Timestep import Timestep +if sys.version_info < (3, 12): + from datapoint.Forecast import Forecast + from datapoint.Site import Site + from datapoint.Timestep import Timestep @dataclass diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index cdd506790ef..389462d573a 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -2,9 +2,7 @@ from __future__ import annotations import logging - -import datapoint -from datapoint.Site import Site +import sys from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.dt import utcnow @@ -12,6 +10,11 @@ from homeassistant.util.dt import utcnow from .const import MODE_3HOURLY from .data import MetOfficeData +if sys.version_info < (3, 12): + import datapoint + from datapoint.Site import Site + + _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json index cfe6e6de9cd..9291f22f3b7 100644 --- a/homeassistant/components/metoffice/manifest.json +++ b/homeassistant/components/metoffice/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/metoffice", "iot_class": "cloud_polling", "loggers": ["datapoint"], - "requirements": ["datapoint==0.9.8"] + "requirements": ["datapoint==0.9.8;python_version<'3.12'"] } diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 8c5c206ae9f..8b0252e7fa7 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -402,6 +402,11 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Memory profiling is not supported on Python 3.12. Please use Python 3.11." + ) + from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 1b33c778843..eeb0a182ee3 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,5 +5,9 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"] + "requirements": [ + "pyprof2calltree==1.4.5", + "guppy3==3.1.3;python_version<'3.12'", + "objgraph==3.5.0" + ] } diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 80ed6164e74..bd034053a34 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,8 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.2"] + "requirements": [ + "RestrictedPython==6.2;python_version<'3.12'", + "RestrictedPython==7.0a1.dev0;python_version>='3.12'" + ] } diff --git a/homeassistant/components/snmp/device_tracker.py b/homeassistant/components/snmp/device_tracker.py index 696b079fd5e..7ca31bae618 100644 --- a/homeassistant/components/snmp/device_tracker.py +++ b/homeassistant/components/snmp/device_tracker.py @@ -3,9 +3,8 @@ from __future__ import annotations import binascii import logging +import sys -from pysnmp.entity import config as cfg -from pysnmp.entity.rfc3413.oneliner import cmdgen import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -15,6 +14,7 @@ from homeassistant.components.device_tracker import ( ) from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -26,6 +26,11 @@ from .const import ( DEFAULT_COMMUNITY, ) +if sys.version_info < (3, 12): + from pysnmp.entity import config as cfg + from pysnmp.entity.rfc3413.oneliner import cmdgen + + _LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( @@ -41,6 +46,10 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( def get_scanner(hass: HomeAssistant, config: ConfigType) -> SnmpScanner | None: """Validate the configuration and return an SNMP scanner.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) scanner = SnmpScanner(config[DOMAIN]) return scanner if scanner.success_init else None diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index a5915183ad0..58cd12d611f 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -3,20 +3,8 @@ from __future__ import annotations from datetime import timedelta import logging +import sys -from pysnmp.error import PySnmpError -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - Udp6TransportTarget, - UdpTransportTarget, - UsmUserData, - getCmd, -) import voluptuous as vol from homeassistant.components.sensor import CONF_STATE_CLASS, PLATFORM_SCHEMA @@ -33,6 +21,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -67,6 +56,21 @@ from .const import ( SNMP_VERSIONS, ) +if sys.version_info < (3, 12): + from pysnmp.error import PySnmpError + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + Udp6TransportTarget, + UdpTransportTarget, + UsmUserData, + getCmd, + ) + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) @@ -111,6 +115,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) diff --git a/homeassistant/components/snmp/switch.py b/homeassistant/components/snmp/switch.py index d0fe393d550..e94c6991601 100644 --- a/homeassistant/components/snmp/switch.py +++ b/homeassistant/components/snmp/switch.py @@ -2,34 +2,9 @@ from __future__ import annotations import logging +import sys from typing import Any -import pysnmp.hlapi.asyncio as hlapi -from pysnmp.hlapi.asyncio import ( - CommunityData, - ContextData, - ObjectIdentity, - ObjectType, - SnmpEngine, - UdpTransportTarget, - UsmUserData, - getCmd, - setCmd, -) -from pysnmp.proto.rfc1902 import ( - Counter32, - Counter64, - Gauge32, - Integer, - Integer32, - IpAddress, - Null, - ObjectIdentifier, - OctetString, - Opaque, - TimeTicks, - Unsigned32, -) import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchEntity @@ -42,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -67,6 +43,34 @@ from .const import ( SNMP_VERSIONS, ) +if sys.version_info < (3, 12): + import pysnmp.hlapi.asyncio as hlapi + from pysnmp.hlapi.asyncio import ( + CommunityData, + ContextData, + ObjectIdentity, + ObjectType, + SnmpEngine, + UdpTransportTarget, + UsmUserData, + getCmd, + setCmd, + ) + from pysnmp.proto.rfc1902 import ( + Counter32, + Counter64, + Gauge32, + Integer, + Integer32, + IpAddress, + Null, + ObjectIdentifier, + OctetString, + Opaque, + TimeTicks, + Unsigned32, + ) + _LOGGER = logging.getLogger(__name__) CONF_COMMAND_OID = "command_oid" @@ -77,21 +81,22 @@ DEFAULT_COMMUNITY = "private" DEFAULT_PAYLOAD_OFF = 0 DEFAULT_PAYLOAD_ON = 1 -MAP_SNMP_VARTYPES = { - "Counter32": Counter32, - "Counter64": Counter64, - "Gauge32": Gauge32, - "Integer32": Integer32, - "Integer": Integer, - "IpAddress": IpAddress, - "Null": Null, - # some work todo to support tuple ObjectIdentifier, this just supports str - "ObjectIdentifier": ObjectIdentifier, - "OctetString": OctetString, - "Opaque": Opaque, - "TimeTicks": TimeTicks, - "Unsigned32": Unsigned32, -} +if sys.version_info < (3, 12): + MAP_SNMP_VARTYPES = { + "Counter32": Counter32, + "Counter64": Counter64, + "Gauge32": Gauge32, + "Integer32": Integer32, + "Integer": Integer, + "IpAddress": IpAddress, + "Null": Null, + # some work todo to support tuple ObjectIdentifier, this just supports str + "ObjectIdentifier": ObjectIdentifier, + "OctetString": OctetString, + "Opaque": Opaque, + "TimeTicks": TimeTicks, + "Unsigned32": Unsigned32, + } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -127,6 +132,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP switch.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "SNMP is not supported on Python 3.12. Please use Python 3.11." + ) name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) diff --git a/homeassistant/components/vulcan/__init__.py b/homeassistant/components/vulcan/__init__.py index 0bfd09d590d..b52b4181510 100644 --- a/homeassistant/components/vulcan/__init__.py +++ b/homeassistant/components/vulcan/__init__.py @@ -1,21 +1,32 @@ """The Vulcan component.""" +import sys from aiohttp import ClientConnectorError -from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +if sys.version_info < (3, 12): + from vulcan import Account, Keystore, UnauthorizedCertificateException, Vulcan + PLATFORMS = [Platform.CALENDAR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Uonet+ Vulcan integration.""" + if sys.version_info >= (3, 12): + raise HomeAssistantError( + "Uonet+ Vulcan is not supported on Python 3.12. Please use Python 3.11." + ) hass.data.setdefault(DOMAIN, {}) try: keystore = Keystore.load(entry.data["keystore"]) diff --git a/pyproject.toml b/pyproject.toml index 9b8642172ce..15a33f2cbf3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -439,6 +439,8 @@ filterwarnings = [ "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", # https://github.com/michaeldavie/env_canada/blob/v0.5.37/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", + # https://github.com/allenporter/ical/pull/215 - v5.0.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", # https://github.com/bachya/regenmaschine/blob/2023.08.0/regenmaschine/client.py#L51 "ignore:ssl.TLSVersion.SSLv3 is deprecated:DeprecationWarning:regenmaschine.client", @@ -451,6 +453,13 @@ filterwarnings = [ # -- tracked upstream / open PRs # https://github.com/caronc/apprise/issues/659 - v1.4.5 "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:apprise.AppriseLocal", + # https://github.com/kiorky/croniter/issues/49 - v1.4.1 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:croniter.croniter", + # https://github.com/spulec/freezegun/issues/508 - v1.2.2 + # https://github.com/spulec/freezegun/pull/511 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:freezegun.api", + # https://github.com/influxdata/influxdb-client-python/issues/603 - v1.37.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb_client.client.write.point", # https://github.com/beetbox/mediafile/issues/67 - v0.12.0 "ignore:'imghdr' is deprecated and slated for removal in Python 3.13:DeprecationWarning:mediafile", # https://github.com/eclipse/paho.mqtt.python/issues/653 - v1.6.1 @@ -458,6 +467,8 @@ filterwarnings = [ "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:paho.mqtt.client", # https://github.com/PythonCharmers/python-future/issues/488 - v0.18.3 "ignore:the imp module is deprecated in favour of importlib and slated for removal in Python 3.12:DeprecationWarning:future.standard_library", + # https://github.com/frenck/python-toonapi/pull/9 - v0.2.1 - 2021-09-23 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:toonapi.models", # https://github.com/foxel/python_ndms2_client/issues/6 - v0.1.2 "ignore:'telnetlib' is deprecated and slated for removal in Python 3.13:DeprecationWarning:ndms2_client.connection", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 @@ -465,14 +476,40 @@ filterwarnings = [ "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", # -- fixed, waiting for release / update + # https://github.com/ludeeus/aiogithubapi/pull/208 - >=23.9.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiogithubapi.namespaces.events", + # https://github.com/bachya/aiopurpleair/pull/200 - >2023.08.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:aiopurpleair.helpers.validators", + # https://github.com/scrapinghub/dateparser/pull/1179 - >1.1.8 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:dateparser.timezone_parser", + # https://github.com/zopefoundation/DateTime/pull/55 - >5.2 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:DateTime.pytz_support", # https://github.com/kurtmckee/feedparser/issues/330 - >6.0.10 "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:feedparser.encodings", # https://github.com/jaraco/jaraco.abode/commit/9e3e789efc96cddcaa15f920686bbeb79a7469e0 - update jaraco.abode to >=5.1.0 "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", + # https://github.com/pytest-dev/pytest/pull/10894 - >=7.4.0 + "ignore:ast.(Str|Num|NameConstant) is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", + "ignore:Attribute s is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", + # https://github.com/bachya/pytile/pull/280 - >2023.08.0 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", + # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.protocol", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:miio.miioprotocol", + # https://github.com/hunterjm/python-onvif-zeep-async/pull/51 - >3.1.12 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:onvif.client", + # Fixed upstream in python-telegram-bot - >=20.0 + "ignore:python-telegram-bot is using upstream urllib3:UserWarning:telegram.utils.request", + # https://github.com/ludeeus/pytraccar/pull/15 - >1.0.0 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytraccar.client", + # https://github.com/zopefoundation/RestrictedPython/pull/259 - >7.0a1.dev0 + "ignore:ast\\.(Str|Num) is deprecated and will be removed in Python 3.14:DeprecationWarning:RestrictedPython.transformer", # https://github.com/grahamwetzler/smart-meter-texas/pull/143 - >0.5.3 "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", + # https://github.com/Bluetooth-Devices/xiaomi-ble/pull/59 - >0.21.1 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:xiaomi_ble.parser", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -482,6 +519,17 @@ filterwarnings = [ # Locale changes might take some time to resolve upstream "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:homematicip.base.base_connection", "ignore:Use setlocale\\(\\), getencoding\\(\\) and getlocale\\(\\) instead:DeprecationWarning:micloud.micloud", + # https://github.com/protocolbuffers/protobuf - v4.24.4 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:google.protobuf.internal.well_known_types", + "ignore:Type google._upb._message.(Message|Scalar)MapContainer uses PyType_Spec with a metaclass that has custom tp_new. .* Python 3.14:DeprecationWarning", + # https://github.com/googleapis/google-auth-library-python/blob/v2.23.3/google/auth/_helpers.py#L95 - v2.23.3 + "ignore:datetime.*utcnow\\(\\) is deprecated:DeprecationWarning:google.auth._helpers", + # https://github.com/googleapis/proto-plus-python/blob/v1.22.3/proto/datetime_helpers.py#L24 - v1.22.3 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", + # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", + # https://github.com/Python-MyQ/Python-MyQ - v3.1.11 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pymyq.(api|account)", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 "ignore:It looks like you're parsing an XML document using an HTML parser:UserWarning:bs4.builder", @@ -495,10 +543,13 @@ filterwarnings = [ "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:aiomodernforms.modernforms", # https://pypi.org/project/directv/ - v0.4.0 - 2020-09-12 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:directv.directv", + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:directv.models", # https://pypi.org/project/emulated-roku/ - v0.2.1 - 2020-01-23 (archived) "ignore:loop argument is deprecated:DeprecationWarning:emulated_roku", # https://pypi.org/project/foobot_async/ - v1.0.0 - 2020-11-24 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:foobot_async", + # https://pypi.org/project/influxdb/ - v5.3.1 - 2020-11-11 (archived) + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:influxdb.line_protocol", # https://pypi.org/project/lark-parser/ - v0.12.0 - 2021-08-30 -> moved to `lark` # https://pypi.org/project/commentjson/ - v0.9.0 - 2020-10-05 # https://github.com/vaidik/commentjson/issues/51 @@ -507,11 +558,17 @@ filterwarnings = [ "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:lomond.session", + # https://pypi.org/project/oauth2client/ - v4.1.3 - 2018-09-07 (archived) + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:oauth2client.client", # https://pypi.org/project/passlib/ - v1.7.4 - 2020-10-08 "ignore:'crypt' is deprecated and slated for removal in Python 3.13:DeprecationWarning:passlib.utils", + # https://pypi.org/project/PyMetEireann/ - v2021.8.0 - 2021-08-16 + "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:meteireann", # https://pypi.org/project/pyqwikswitch/ - v0.94 - 2019-08-19 "ignore:client.loop property is deprecated:DeprecationWarning:pyqwikswitch.async_", "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:pyqwikswitch.async_", + # https://pypi.org/project/Rx/ - v3.2.0 - 2021-04-25 + "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:rx.internal.constants", # https://pypi.org/project/rxv/ - v0.7.0 - 2021-10-10 "ignore:defusedxml.cElementTree is deprecated, import from defusedxml.ElementTree instead:DeprecationWarning:rxv.ssdp", # https://pypi.org/project/vilfo-api-client/ - v0.4.1 - 2021-11-06 diff --git a/requirements_all.txt b/requirements_all.txt index a18c5f19d75..16538b82fb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -122,7 +122,10 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2 +RestrictedPython==6.2;python_version<'3.12' + +# homeassistant.components.python_script +RestrictedPython==7.0a1.dev0;python_version>='3.12' # homeassistant.components.remember_the_milk RtmAPI==0.7.2 @@ -642,7 +645,7 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.8 +datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth dbus-fast==2.11.1 @@ -939,7 +942,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.3 +guppy3==3.1.3;python_version<'3.12' # homeassistant.components.iaqualink h2==4.1.0 @@ -2697,7 +2700,7 @@ watchdog==2.3.1 waterfurnace==1.1.0 # homeassistant.components.cisco_webex_teams -webexteamssdk==1.1.1 +webexteamssdk==1.1.1;python_version<'3.12' # homeassistant.components.assist_pipeline webrtc-noise-gain==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84419d16cc1..14e933c08e2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,10 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.2 +RestrictedPython==6.2;python_version<'3.12' + +# homeassistant.components.python_script +RestrictedPython==7.0a1.dev0;python_version>='3.12' # homeassistant.components.remember_the_milk RtmAPI==0.7.2 @@ -525,7 +528,7 @@ crownstone-uart==2.1.0 datadog==0.15.0 # homeassistant.components.metoffice -datapoint==0.9.8 +datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth dbus-fast==2.11.1 @@ -740,7 +743,7 @@ growattServer==1.3.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.3 +guppy3==3.1.3;python_version<'3.12' # homeassistant.components.iaqualink h2==4.1.0 diff --git a/tests/components/apache_kafka/conftest.py b/tests/components/apache_kafka/conftest.py new file mode 100644 index 00000000000..9391ccdd380 --- /dev/null +++ b/tests/components/apache_kafka/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/brother/__init__.py b/tests/components/brother/__init__.py index 8e24c2d8058..3176fa7fc28 100644 --- a/tests/components/brother/__init__.py +++ b/tests/components/brother/__init__.py @@ -1,13 +1,16 @@ """Tests for Brother Printer integration.""" import json +import sys from unittest.mock import patch -from homeassistant.components.brother.const import DOMAIN from homeassistant.const import CONF_HOST, CONF_TYPE from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture +if sys.version_info < (3, 12): + from homeassistant.components.brother.const import DOMAIN + async def init_integration( hass: HomeAssistant, skip_setup: bool = False diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 9e81cce9d12..558b3b8ac3e 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -1,9 +1,13 @@ """Test fixtures for brother.""" from collections.abc import Generator +import sys from unittest.mock import AsyncMock, patch import pytest +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: diff --git a/tests/components/metoffice/conftest.py b/tests/components/metoffice/conftest.py index b1d1c9f508e..1633fae5ee8 100644 --- a/tests/components/metoffice/conftest.py +++ b/tests/components/metoffice/conftest.py @@ -1,9 +1,14 @@ """Fixtures for Met Office weather integration tests.""" +import sys from unittest.mock import patch -from datapoint.exceptions import APIException import pytest +if sys.version_info < (3, 12): + from datapoint.exceptions import APIException +else: + collect_ignore_glob = ["test_*.py"] + @pytest.fixture def mock_simple_manager_fail(): diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 7c2aeb2a29a..01f4c4ff510 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -3,6 +3,7 @@ from datetime import timedelta from functools import lru_cache import os from pathlib import Path +import sys from unittest.mock import patch from lru import LRU # pylint: disable=no-name-in-module @@ -63,6 +64,9 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() +@pytest.mark.skipif( + sys.version_info >= (3, 12), reason="not yet available on Python 3.12" +) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -94,6 +98,24 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() +@pytest.mark.skipif(sys.version_info < (3, 12), reason="still works on python 3.11") +async def test_memory_usage_py312(hass: HomeAssistant, tmp_path: Path) -> None: + """Test raise an error on python3.11.""" + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) + with pytest.raises( + HomeAssistantError, + match="Memory profiling is not supported on Python 3.12. Please use Python 3.11.", + ): + await hass.services.async_call( + DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True + ) + + async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/snmp/conftest.py b/tests/components/snmp/conftest.py new file mode 100644 index 00000000000..05a518ad7f3 --- /dev/null +++ b/tests/components/snmp/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection for Python 3.12.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] diff --git a/tests/components/vulcan/conftest.py b/tests/components/vulcan/conftest.py new file mode 100644 index 00000000000..05a518ad7f3 --- /dev/null +++ b/tests/components/vulcan/conftest.py @@ -0,0 +1,5 @@ +"""Skip test collection for Python 3.12.""" +import sys + +if sys.version_info >= (3, 12): + collect_ignore_glob = ["test_*.py"] From 9b785ef766ccb5051ca3b2677030aaa90c25d296 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 10 Oct 2023 22:11:55 +0200 Subject: [PATCH 350/968] Add Discovergy to strict-typing (#101782) --- .strict-typing | 1 + homeassistant/components/discovergy/config_flow.py | 9 --------- homeassistant/components/discovergy/system_health.py | 4 +++- mypy.ini | 10 ++++++++++ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.strict-typing b/.strict-typing index 6b2c52f42f6..f59323ef76c 100644 --- a/.strict-typing +++ b/.strict-typing @@ -103,6 +103,7 @@ homeassistant.components.devolo_home_control.* homeassistant.components.devolo_home_network.* homeassistant.components.dhcp.* homeassistant.components.diagnostics.* +homeassistant.components.discovergy.* homeassistant.components.dlna_dmr.* homeassistant.components.dnsip.* homeassistant.components.doorbird.* diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index e035661db10..b3dee2d82a0 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -60,15 +60,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" self.existing_entry = await self.async_set_unique_id(self.context["unique_id"]) - if entry_data is None: - return self.async_show_form( - step_id="reauth", - data_schema=make_schema( - self.existing_entry.data[CONF_EMAIL] or "", - self.existing_entry.data[CONF_PASSWORD] or "", - ), - ) - return await self._validate_and_save(entry_data, step_id="reauth") async def _validate_and_save( diff --git a/homeassistant/components/discovergy/system_health.py b/homeassistant/components/discovergy/system_health.py index 2baeb0e5f6e..61fe4099596 100644 --- a/homeassistant/components/discovergy/system_health.py +++ b/homeassistant/components/discovergy/system_health.py @@ -1,4 +1,6 @@ """Provide info to system health.""" +from typing import Any + from pydiscovergy.const import API_BASE from homeassistant.components import system_health @@ -13,7 +15,7 @@ def async_register( register.async_register_info(system_health_info) -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" return { "api_endpoint_reachable": system_health.async_check_can_reach_url( diff --git a/mypy.ini b/mypy.ini index c2ecac66946..94ad7cc018b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -792,6 +792,16 @@ warn_return_any = true warn_unreachable = true no_implicit_reexport = true +[mypy-homeassistant.components.discovergy.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dlna_dmr.*] check_untyped_defs = true disallow_incomplete_defs = true From ffb752c8040d573cb630536f1e02946d04a47f9f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 11 Oct 2023 00:06:42 +0200 Subject: [PATCH 351/968] Subscribe to Withings webhooks outside of coordinator (#101759) * Subscribe to Withings webhooks outside of coordinator * Subscribe to Withings webhooks outside of coordinator * Update homeassistant/components/withings/__init__.py Co-authored-by: J. Nick Koston * Update homeassistant/components/withings/__init__.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- homeassistant/components/withings/__init__.py | 57 +++++++++++++++++- .../components/withings/coordinator.py | 59 ++----------------- tests/components/withings/conftest.py | 4 +- tests/components/withings/test_init.py | 2 +- 4 files changed, 64 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 810ad49171c..16606a40645 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -4,8 +4,10 @@ For more details about this platform, please refer to the documentation at """ from __future__ import annotations +import asyncio from collections.abc import Awaitable, Callable import contextlib +from datetime import timedelta from typing import Any from aiohttp.hdrs import METH_HEAD, METH_POST @@ -78,6 +80,8 @@ CONFIG_SCHEMA = vol.Schema( }, extra=vol.ALLOW_EXTRA, ) +SUBSCRIBE_DELAY = timedelta(seconds=5) +UNSUBSCRIBE_DELAY = timedelta(seconds=1) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -141,7 +145,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) -> None: LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - await hass.data[DOMAIN][entry.entry_id].async_unsubscribe_webhooks() + await async_unsubscribe_webhooks(client) + coordinator.webhook_subscription_listener(False) async def register_webhook( _: Any, @@ -170,7 +175,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: get_webhook_handler(coordinator), ) - await hass.data[DOMAIN][entry.entry_id].async_subscribe_webhooks(webhook_url) + await async_subscribe_webhooks(client, webhook_url) + coordinator.webhook_subscription_listener(True) LOGGER.debug("Register Withings webhook: %s", webhook_url) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) @@ -213,6 +219,53 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) +async def async_subscribe_webhooks( + client: ConfigEntryWithingsApi, webhook_url: str +) -> None: + """Subscribe to Withings webhooks.""" + await async_unsubscribe_webhooks(client) + + notification_to_subscribe = { + NotifyAppli.WEIGHT, + NotifyAppli.CIRCULATORY, + NotifyAppli.ACTIVITY, + NotifyAppli.SLEEP, + NotifyAppli.BED_IN, + NotifyAppli.BED_OUT, + } + + for notification in notification_to_subscribe: + LOGGER.debug( + "Subscribing %s for %s in %s seconds", + webhook_url, + notification, + SUBSCRIBE_DELAY.total_seconds(), + ) + # Withings will HTTP HEAD the callback_url and needs some downtime + # between each call or there is a higher chance of failure. + await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) + await client.async_notify_subscribe(webhook_url, notification) + + +async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None: + """Unsubscribe to all Withings webhooks.""" + current_webhooks = await client.async_notify_list() + + for webhook_configuration in current_webhooks.profiles: + LOGGER.debug( + "Unsubscribing %s for %s in %s seconds", + webhook_configuration.callbackurl, + webhook_configuration.appli, + UNSUBSCRIBE_DELAY.total_seconds(), + ) + # Quick calls to Withings can result in the service returning errors. + # Give them some time to cool down. + await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) + await client.async_notify_revoke( + webhook_configuration.callbackurl, webhook_configuration.appli + ) + + async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 128d4e39193..2ec2804814b 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,5 +1,4 @@ """Withings coordinator.""" -import asyncio from collections.abc import Callable from datetime import timedelta from typing import Any @@ -24,9 +23,6 @@ from homeassistant.util import dt as dt_util from .api import ConfigEntryWithingsApi from .const import LOGGER, Measurement -SUBSCRIBE_DELAY = timedelta(seconds=5) -UNSUBSCRIBE_DELAY = timedelta(seconds=1) - WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli | GetSleepSummaryField | MeasureType, Measurement ] = { @@ -84,55 +80,12 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) self._client = client - async def async_subscribe_webhooks(self, webhook_url: str) -> None: - """Subscribe to webhooks.""" - await self.async_unsubscribe_webhooks() - - current_webhooks = await self._client.async_notify_list() - - subscribed_notifications = frozenset( - profile.appli - for profile in current_webhooks.profiles - if profile.callbackurl == webhook_url - ) - - notification_to_subscribe = ( - set(NotifyAppli) - - subscribed_notifications - - {NotifyAppli.USER, NotifyAppli.UNKNOWN} - ) - - for notification in notification_to_subscribe: - LOGGER.debug( - "Subscribing %s for %s in %s seconds", - webhook_url, - notification, - SUBSCRIBE_DELAY.total_seconds(), - ) - # Withings will HTTP HEAD the callback_url and needs some downtime - # between each call or there is a higher chance of failure. - await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) - await self._client.async_notify_subscribe(webhook_url, notification) - self.update_interval = None - - async def async_unsubscribe_webhooks(self) -> None: - """Unsubscribe to webhooks.""" - current_webhooks = await self._client.async_notify_list() - - for webhook_configuration in current_webhooks.profiles: - LOGGER.debug( - "Unsubscribing %s for %s in %s seconds", - webhook_configuration.callbackurl, - webhook_configuration.appli, - UNSUBSCRIBE_DELAY.total_seconds(), - ) - # Quick calls to Withings can result in the service returning errors. - # Give them some time to cool down. - await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) - await self._client.async_notify_revoke( - webhook_configuration.callbackurl, webhook_configuration.appli - ) - self.update_interval = UPDATE_INTERVAL + def webhook_subscription_listener(self, connected: bool) -> None: + """Call when webhook status changed.""" + if connected: + self.update_interval = None + else: + self.update_interval = UPDATE_INTERVAL async def _async_update_data(self) -> dict[Measurement, Any]: try: diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 3fc2a3c6461..ad310639b43 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -160,10 +160,10 @@ def disable_webhook_delay(): mock = AsyncMock() with patch( - "homeassistant.components.withings.coordinator.SUBSCRIBE_DELAY", + "homeassistant.components.withings.SUBSCRIBE_DELAY", timedelta(seconds=0), ), patch( - "homeassistant.components.withings.coordinator.UNSUBSCRIBE_DELAY", + "homeassistant.components.withings.UNSUBSCRIBE_DELAY", timedelta(seconds=0), ): yield mock diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index dd112671945..ab83bbcfb36 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -126,7 +126,7 @@ async def test_data_manager_webhook_subscription( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - assert withings.async_notify_subscribe.call_count == 4 + assert withings.async_notify_subscribe.call_count == 6 webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" From bd38fd9516fb970d1a9ef97f627340480274aaf3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 10 Oct 2023 20:48:46 -0700 Subject: [PATCH 352/968] Add google calendar required feature for create event service (#101741) * Add google calendar required feature for create event service * Update docstring --- homeassistant/components/google/calendar.py | 1 + tests/components/google/test_init.py | 53 +++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 9559a06d49c..bd0fe18912e 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -240,6 +240,7 @@ async def async_setup_entry( SERVICE_CREATE_EVENT, CREATE_EVENT_SCHEMA, async_create_event, + required_features=CalendarEntityFeature.CREATE_EVENT, ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 233635510e0..9ede0573922 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -576,6 +576,59 @@ async def test_add_event_date_time( } +@pytest.mark.parametrize( + "calendars_config", + [ + [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "backyard_light", + "name": "Backyard Light", + "search": "#Backyard", + }, + ], + } + ], + ], +) +async def test_unsupported_create_event( + hass: HomeAssistant, + mock_calendars_yaml: Mock, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + mock_insert_event: Callable[[str, dict[str, Any]], None], + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test create event service call is unsupported for virtual calendars.""" + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + start_datetime = datetime.datetime.now(tz=zoneinfo.ZoneInfo("America/Regina")) + delta = datetime.timedelta(days=3, hours=3) + end_datetime = start_datetime + delta + + with pytest.raises(HomeAssistantError, match="does not support this service"): + await hass.services.async_call( + DOMAIN, + "create_event", + { + # **data, + "start_date_time": start_datetime.isoformat(), + "end_date_time": end_datetime.isoformat(), + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, + }, + target={"entity_id": "calendar.backyard_light"}, + blocking=True, + ) + + async def test_add_event_failure( hass: HomeAssistant, component_setup: ComponentSetup, From 39fd5897cb0b9b88153278af11f4c3e2aea1d951 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Oct 2023 20:11:58 -1000 Subject: [PATCH 353/968] Small typing cleanups for HomeKit (#101790) --- .../components/homekit/aidmanager.py | 2 +- .../components/homekit/config_flow.py | 6 +-- .../components/homekit/type_covers.py | 30 ++++++++------- homeassistant/components/homekit/type_fans.py | 37 ++++++++++--------- 4 files changed, 40 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 9c3d9e7929c..0deb4500197 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -140,6 +140,6 @@ class AccessoryAidStorage: return await self.store.async_save(self._data_to_save()) @callback - def _data_to_save(self) -> dict: + def _data_to_save(self) -> dict[str, dict[str, int]]: """Return data of entity map to store in a file.""" return {ALLOCATIONS_KEY: self.allocations} diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index c43093d92b4..a6984ae2121 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -257,7 +257,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) ) - async def async_step_accessory(self, accessory_input: dict) -> FlowResult: + async def async_step_accessory(self, accessory_input: dict[str, Any]) -> FlowResult: """Handle creation a single accessory in accessory mode.""" entity_id = accessory_input[CONF_ENTITY_ID] port = accessory_input[CONF_PORT] @@ -283,7 +283,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): title=f"{name}:{entry_data[CONF_PORT]}", data=entry_data ) - async def async_step_import(self, user_input: dict) -> FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import from yaml.""" if not self._async_is_unique_name_port(user_input): return self.async_abort(reason="port_name_in_use") @@ -318,7 +318,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return suggested_name @callback - def _async_is_unique_name_port(self, user_input: dict[str, str]) -> bool: + def _async_is_unique_name_port(self, user_input: dict[str, Any]) -> bool: """Determine is a name or port is already used.""" name = user_input[CONF_NAME] port = user_input[CONF_PORT] diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index ea0a5054ffd..c8599b99664 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,5 +1,6 @@ """Class to hold all cover accessories.""" import logging +from typing import Any from pyhap.const import ( CATEGORY_DOOR, @@ -7,6 +8,7 @@ from pyhap.const import ( CATEGORY_WINDOW, CATEGORY_WINDOW_COVERING, ) +from pyhap.service import Service from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -98,7 +100,7 @@ class GarageDoorOpener(HomeAccessory): and support no more than open, close, and stop. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) state = self.hass.states.get(self.entity_id) @@ -203,12 +205,12 @@ class OpeningDeviceBase(HomeAccessory): WindowCovering """ - def __init__(self, *args, category, service): + def __init__(self, *args: Any, category: int, service: Service) -> None: """Initialize a OpeningDeviceBase accessory object.""" super().__init__(*args, category=category) state = self.hass.states.get(self.entity_id) - - self.features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + assert state + self.features: int = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) self._supports_stop = self.features & CoverEntityFeature.STOP self.chars = [] if self._supports_stop: @@ -276,14 +278,15 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): The cover entity must support: set_cover_position. """ - def __init__(self, *args, category, service): + def __init__(self, *args: Any, category: int, service: Service) -> None: """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=category, service=service) state = self.hass.states.get(self.entity_id) + assert state self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) - target_args = {"value": 0} + target_args: dict[str, Any] = {"value": 0} if self.features & CoverEntityFeature.SET_POSITION: target_args["setter_callback"] = self.move_cover else: @@ -307,7 +310,7 @@ class OpeningDevice(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - def move_cover(self, value): + def move_cover(self, value: int) -> None: """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value} @@ -338,7 +341,7 @@ class Door(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Door accessory object.""" super().__init__(*args, category=CATEGORY_DOOR, service=SERV_DOOR) @@ -350,7 +353,7 @@ class Window(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Window accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW, service=SERV_WINDOW) @@ -362,7 +365,7 @@ class WindowCovering(OpeningDevice): The entity must support: set_cover_position. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WindowCovering accessory object.""" super().__init__( *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING @@ -377,12 +380,13 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): stop_cover (optional). """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WindowCoveringBasic accessory object.""" super().__init__( *args, category=CATEGORY_WINDOW_COVERING, service=SERV_WINDOW_COVERING ) state = self.hass.states.get(self.entity_id) + assert state self.char_current_position = self.serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) @@ -394,7 +398,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): ) self.async_update_state(state) - def move_cover(self, value): + def move_cover(self, value: int) -> None: """Move cover to value if call came from HomeKit.""" _LOGGER.debug("%s: Set position to %d", self.entity_id, value) @@ -436,7 +440,7 @@ class WindowCoveringBasic(OpeningDeviceBase, HomeAccessory): super().async_update_state(new_state) -def _hass_state_to_position_start(state): +def _hass_state_to_position_start(state: str) -> int: """Convert hass state to homekit position state.""" if state == STATE_OPENING: return HK_POSITION_GOING_TO_MAX diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py index 0ace0acd0b9..9b27653e4cf 100644 --- a/homeassistant/components/homekit/type_fans.py +++ b/homeassistant/components/homekit/type_fans.py @@ -1,5 +1,6 @@ -"""Class to hold all light accessories.""" +"""Class to hold all fan accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_FAN @@ -27,7 +28,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -54,12 +55,12 @@ class Fan(HomeAccessory): Currently supports: state, speed, oscillate, direction. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a new Fan accessory object.""" super().__init__(*args, category=CATEGORY_FAN) - self.chars = [] + self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) - + assert state self._reload_on_change_attrs.extend( ( ATTR_PERCENTAGE_STEP, @@ -69,7 +70,7 @@ class Fan(HomeAccessory): features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) percentage_step = state.attributes.get(ATTR_PERCENTAGE_STEP, 1) - self.preset_modes = state.attributes.get(ATTR_PRESET_MODES) + self.preset_modes: list[str] | None = state.attributes.get(ATTR_PRESET_MODES) if features & FanEntityFeature.DIRECTION: self.chars.append(CHAR_ROTATION_DIRECTION) @@ -136,7 +137,7 @@ class Fan(HomeAccessory): self.async_update_state(state) serv_fan.setter_callback = self._set_chars - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Fan _set_chars: %s", char_values) if CHAR_ACTIVE in char_values: if char_values[CHAR_ACTIVE]: @@ -167,23 +168,23 @@ class Fan(HomeAccessory): if CHAR_TARGET_FAN_STATE in char_values: self.set_single_preset_mode(char_values[CHAR_TARGET_FAN_STATE]) - def set_single_preset_mode(self, value): + def set_single_preset_mode(self, value: int) -> None: """Set auto call came from HomeKit.""" - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} if value: + assert self.preset_modes _LOGGER.debug( "%s: Set auto to 1 (%s)", self.entity_id, self.preset_modes[0] ) params[ATTR_PRESET_MODE] = self.preset_modes[0] self.async_call_service(DOMAIN, SERVICE_SET_PRESET_MODE, params) - else: - current_state = self.hass.states.get(self.entity_id) - percentage = current_state.attributes.get(ATTR_PERCENTAGE) or 50 + elif current_state := self.hass.states.get(self.entity_id): + percentage: float = current_state.attributes.get(ATTR_PERCENTAGE) or 50.0 params[ATTR_PERCENTAGE] = percentage _LOGGER.debug("%s: Set auto to 0", self.entity_id) self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) - def set_preset_mode(self, value, preset_mode): + def set_preset_mode(self, value: int, preset_mode: str) -> None: """Set preset_mode if call came from HomeKit.""" _LOGGER.debug( "%s: Set preset_mode %s to %d", self.entity_id, preset_mode, value @@ -195,35 +196,35 @@ class Fan(HomeAccessory): else: self.async_call_service(DOMAIN, SERVICE_TURN_ON, params) - def set_state(self, value): + def set_state(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_direction(self, value): + def set_direction(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set direction to %d", self.entity_id, value) direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} self.async_call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction) - def set_oscillating(self, value): + def set_oscillating(self, value: int) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set oscillating to %d", self.entity_id, value) oscillating = value == 1 params = {ATTR_ENTITY_ID: self.entity_id, ATTR_OSCILLATING: oscillating} self.async_call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating) - def set_percentage(self, value): + def set_percentage(self, value: float) -> None: """Set state if call came from HomeKit.""" _LOGGER.debug("%s: Set speed to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_PERCENTAGE: value} self.async_call_service(DOMAIN, SERVICE_SET_PERCENTAGE, params, value) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update fan after state change.""" # Handle State state = new_state.state From 7d1105228b830605bf980702a5ece30e3687e5c9 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 11 Oct 2023 09:46:02 +0200 Subject: [PATCH 354/968] Allow resetting time in google_travel_time (#88256) Co-authored-by: Robert Resch --- .../google_travel_time/config_flow.py | 131 +++++++++++------ .../components/google_travel_time/const.py | 8 +- .../google_travel_time/strings.json | 55 ++++++- .../google_travel_time/test_config_flow.py | 138 ++++++++++++++++++ 4 files changed, 282 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/google_travel_time/config_flow.py b/homeassistant/components/google_travel_time/config_flow.py index 23d4f2541bd..83e144f6bbd 100644 --- a/homeassistant/components/google_travel_time/config_flow.py +++ b/homeassistant/components/google_travel_time/config_flow.py @@ -8,12 +8,17 @@ from homeassistant.const import CONF_API_KEY, CONF_MODE, CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from .const import ( ALL_LANGUAGES, ARRIVAL_TIME, - AVOID, + AVOID_OPTIONS, CONF_ARRIVAL_TIME, CONF_AVOID, CONF_DEPARTURE_TIME, @@ -30,18 +35,87 @@ from .const import ( DEPARTURE_TIME, DOMAIN, TIME_TYPES, + TRAFFIC_MODELS, TRANSIT_PREFS, - TRANSPORT_TYPE, - TRAVEL_MODE, - TRAVEL_MODEL, + TRANSPORT_TYPES, + TRAVEL_MODES, UNITS, UNITS_IMPERIAL, UNITS_METRIC, ) from .helpers import InvalidApiKeyException, UnknownException, validate_config_entry +OPTIONS_SCHEMA = vol.Schema( + { + vol.Required(CONF_MODE): SelectSelector( + SelectSelectorConfig( + options=TRAVEL_MODES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MODE, + ) + ), + vol.Optional(CONF_LANGUAGE): SelectSelector( + SelectSelectorConfig( + options=sorted(ALL_LANGUAGES), + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LANGUAGE, + ) + ), + vol.Optional(CONF_AVOID): SelectSelector( + SelectSelectorConfig( + options=AVOID_OPTIONS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_AVOID, + ) + ), + vol.Required(CONF_UNITS): SelectSelector( + SelectSelectorConfig( + options=UNITS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_UNITS, + ) + ), + vol.Required(CONF_TIME_TYPE): SelectSelector( + SelectSelectorConfig( + options=TIME_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TIME_TYPE, + ) + ), + vol.Optional(CONF_TIME, default=""): cv.string, + vol.Optional(CONF_TRAFFIC_MODEL): SelectSelector( + SelectSelectorConfig( + options=TRAFFIC_MODELS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRAFFIC_MODEL, + ) + ), + vol.Optional(CONF_TRANSIT_MODE): SelectSelector( + SelectSelectorConfig( + options=TRANSPORT_TYPES, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_MODE, + ) + ), + vol.Optional(CONF_TRANSIT_ROUTING_PREFERENCE): SelectSelector( + SelectSelectorConfig( + options=TRANSIT_PREFS, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_TRANSIT_ROUTING_PREFERENCE, + ) + ), + } +) -def default_options(hass: HomeAssistant) -> dict[str, str | None]: + +def default_options(hass: HomeAssistant) -> dict[str, str]: """Get the default options.""" return { CONF_MODE: "driving", @@ -69,53 +143,20 @@ class GoogleOptionsFlow(config_entries.OptionsFlow): user_input[CONF_DEPARTURE_TIME] = time return self.async_create_entry( title="", - data={k: v for k, v in user_input.items() if v not in (None, "")}, + data=user_input, ) + options = self.config_entry.options.copy() if CONF_ARRIVAL_TIME in self.config_entry.options: - default_time_type = ARRIVAL_TIME - default_time = self.config_entry.options[CONF_ARRIVAL_TIME] + options[CONF_TIME_TYPE] = ARRIVAL_TIME + options[CONF_TIME] = self.config_entry.options[CONF_ARRIVAL_TIME] else: - default_time_type = DEPARTURE_TIME - default_time = self.config_entry.options.get(CONF_DEPARTURE_TIME, "") + options[CONF_TIME_TYPE] = DEPARTURE_TIME + options[CONF_TIME] = self.config_entry.options.get(CONF_DEPARTURE_TIME, "") return self.async_show_form( step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_MODE, default=self.config_entry.options[CONF_MODE] - ): vol.In(TRAVEL_MODE), - vol.Optional( - CONF_LANGUAGE, - default=self.config_entry.options.get(CONF_LANGUAGE), - ): vol.In([None, *ALL_LANGUAGES]), - vol.Optional( - CONF_AVOID, default=self.config_entry.options.get(CONF_AVOID) - ): vol.In([None, *AVOID]), - vol.Optional( - CONF_UNITS, default=self.config_entry.options[CONF_UNITS] - ): vol.In(UNITS), - vol.Optional(CONF_TIME_TYPE, default=default_time_type): vol.In( - TIME_TYPES - ), - vol.Optional(CONF_TIME, default=default_time): cv.string, - vol.Optional( - CONF_TRAFFIC_MODEL, - default=self.config_entry.options.get(CONF_TRAFFIC_MODEL), - ): vol.In([None, *TRAVEL_MODEL]), - vol.Optional( - CONF_TRANSIT_MODE, - default=self.config_entry.options.get(CONF_TRANSIT_MODE), - ): vol.In([None, *TRANSPORT_TYPE]), - vol.Optional( - CONF_TRANSIT_ROUTING_PREFERENCE, - default=self.config_entry.options.get( - CONF_TRANSIT_ROUTING_PREFERENCE - ), - ): vol.In([None, *TRANSIT_PREFS]), - } - ), + data_schema=self.add_suggested_values_to_schema(OPTIONS_SCHEMA, options), ) diff --git a/homeassistant/components/google_travel_time/const.py b/homeassistant/components/google_travel_time/const.py index efc17b22ec1..0535e295b93 100644 --- a/homeassistant/components/google_travel_time/const.py +++ b/homeassistant/components/google_travel_time/const.py @@ -77,11 +77,11 @@ ALL_LANGUAGES = [ "zh-TW", ] -AVOID = ["tolls", "highways", "ferries", "indoor"] +AVOID_OPTIONS = ["tolls", "highways", "ferries", "indoor"] TRANSIT_PREFS = ["less_walking", "fewer_transfers"] -TRANSPORT_TYPE = ["bus", "subway", "train", "tram", "rail"] -TRAVEL_MODE = ["driving", "walking", "bicycling", "transit"] -TRAVEL_MODEL = ["best_guess", "pessimistic", "optimistic"] +TRANSPORT_TYPES = ["bus", "subway", "train", "tram", "rail"] +TRAVEL_MODES = ["driving", "walking", "bicycling", "transit"] +TRAFFIC_MODELS = ["best_guess", "pessimistic", "optimistic"] # googlemaps library uses "metric" or "imperial" terminology in distance_matrix UNITS_METRIC = "metric" diff --git a/homeassistant/components/google_travel_time/strings.json b/homeassistant/components/google_travel_time/strings.json index 270f8fe31e2..e3a13a3d2e3 100644 --- a/homeassistant/components/google_travel_time/strings.json +++ b/homeassistant/components/google_travel_time/strings.json @@ -30,12 +30,65 @@ "time_type": "Time Type", "time": "Time", "avoid": "Avoid", - "traffic_mode": "Traffic Mode", + "traffic_model": "Traffic Model", "transit_mode": "Transit Mode", "transit_routing_preference": "Transit Routing Preference", "units": "Units" } } } + }, + "selector": { + "mode": { + "options": { + "driving": "Driving", + "walking": "Walking", + "bicycling": "Bicycling", + "transit": "Transit" + } + }, + "avoid": { + "options": { + "none": "Avoid nothing", + "tolls": "Tolls", + "highways": "Highways", + "ferries": "Ferries", + "indoor": "Indoor" + } + }, + "units": { + "options": { + "metric": "Metric System", + "imperial": "Imperial System" + } + }, + "time_type": { + "options": { + "arrival_time": "Arrival Time", + "departure_time": "Departure Time" + } + }, + "traffic_model": { + "options": { + "best_guess": "Best Guess", + "pessimistic": "Pessimistic", + "optimistic": "Optimistic" + } + }, + "transit_mode": { + "options": { + "bus": "Bus", + "subway": "Subway", + "train": "Train", + "tram": "Tram", + "rail": "Rail" + } + }, + "transit_routing_preference": { + "options": { + "less_walking": "Less Walking", + "fewer_transfers": "Fewer Transfers" + } + } } } diff --git a/tests/components/google_travel_time/test_config_flow.py b/tests/components/google_travel_time/test_config_flow.py index 9fb381d7d31..15132baf25a 100644 --- a/tests/components/google_travel_time/test_config_flow.py +++ b/tests/components/google_travel_time/test_config_flow.py @@ -257,6 +257,144 @@ async def test_options_flow_departure_time(hass: HomeAssistant, mock_config) -> } +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_DEPARTURE_TIME: "test", + }, + ), + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + }, + ), + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_departure_time(hass: HomeAssistant, mock_config) -> None: + """Test resetting departure time.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: DEPARTURE_TIME, + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + } + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + }, + ), + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_DEPARTURE_TIME: "test", + }, + ), + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_arrival_time(hass: HomeAssistant, mock_config) -> None: + """Test resetting arrival time.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + } + + +@pytest.mark.parametrize( + ("data", "options"), + [ + ( + MOCK_CONFIG, + { + CONF_MODE: "driving", + CONF_LANGUAGE: "en", + CONF_AVOID: "tolls", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "test", + CONF_TRAFFIC_MODEL: "best_guess", + CONF_TRANSIT_MODE: "train", + CONF_TRANSIT_ROUTING_PREFERENCE: "less_walking", + }, + ) + ], +) +@pytest.mark.usefixtures("validate_config_entry") +async def test_reset_options_flow_fields(hass: HomeAssistant, mock_config) -> None: + """Test resetting options flow fields that are not time related to None.""" + result = await hass.config_entries.options.async_init( + mock_config.entry_id, data=None + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_TIME_TYPE: ARRIVAL_TIME, + CONF_TIME: "test", + }, + ) + + assert mock_config.options == { + CONF_MODE: "driving", + CONF_UNITS: UNITS_IMPERIAL, + CONF_ARRIVAL_TIME: "test", + } + + @pytest.mark.usefixtures("validate_config_entry", "bypass_setup") async def test_dupe(hass: HomeAssistant) -> None: """Test setting up the same entry data twice is OK.""" From 1c70cbaebdf65987110c42569144bd31acf62c2e Mon Sep 17 00:00:00 2001 From: Justin Rigling Date: Wed, 11 Oct 2023 02:41:35 -0600 Subject: [PATCH 355/968] Add Opower virtual integration for Portland General Electric (#101800) --- homeassistant/components/portlandgeneral/__init__.py | 1 + homeassistant/components/portlandgeneral/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/portlandgeneral/__init__.py create mode 100644 homeassistant/components/portlandgeneral/manifest.json diff --git a/homeassistant/components/portlandgeneral/__init__.py b/homeassistant/components/portlandgeneral/__init__.py new file mode 100644 index 00000000000..67ab073a01d --- /dev/null +++ b/homeassistant/components/portlandgeneral/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Portland General Electric (PGE).""" diff --git a/homeassistant/components/portlandgeneral/manifest.json b/homeassistant/components/portlandgeneral/manifest.json new file mode 100644 index 00000000000..1f3b00b0992 --- /dev/null +++ b/homeassistant/components/portlandgeneral/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "portlandgeneral", + "name": "Portland General Electric (PGE)", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 92e4286404f..9ee022473a2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4352,6 +4352,11 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "portlandgeneral": { + "name": "Portland General Electric (PGE)", + "integration_type": "virtual", + "supported_by": "opower" + }, "private_ble_device": { "name": "Private BLE Device", "integration_type": "hub", From 7f7c3233bdc0f8c86c7c6e9836013d368d62e4d3 Mon Sep 17 00:00:00 2001 From: Michael Davie Date: Wed, 11 Oct 2023 05:04:33 -0400 Subject: [PATCH 356/968] Bump env_canada to 0.6.0 (#101798) --- homeassistant/components/environment_canada/manifest.json | 2 +- pyproject.toml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 4946c1900ea..d0c34b0cf9a 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/environment_canada", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.5.37"] + "requirements": ["env-canada==0.6.0"] } diff --git a/pyproject.toml b/pyproject.toml index 15a33f2cbf3..46865110200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -437,7 +437,7 @@ filterwarnings = [ # -- design choice 3rd party # https://github.com/gwww/elkm1/blob/2.2.5/elkm1_lib/util.py#L8-L19 "ignore:ssl.TLSVersion.TLSv1 is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/michaeldavie/env_canada/blob/v0.5.37/env_canada/ec_cache.py + # https://github.com/michaeldavie/env_canada/blob/v0.6.0/env_canada/ec_cache.py "ignore:Inheritance class CacheClientSession from ClientSession is discouraged:DeprecationWarning:env_canada.ec_cache", # https://github.com/allenporter/ical/pull/215 - v5.0.0 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:ical.util", diff --git a/requirements_all.txt b/requirements_all.txt index 16538b82fb8..c6dfbc1725b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ enocean==0.50 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.5.37 +env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14e933c08e2..76ca48de574 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -608,7 +608,7 @@ energyzero==0.5.0 enocean==0.50 # homeassistant.components.environment_canada -env-canada==0.5.37 +env-canada==0.6.0 # homeassistant.components.season ephem==4.1.5 From 8acb4dc1b67073fe764d73efad3efa714ee56106 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Wed, 11 Oct 2023 11:32:22 +0200 Subject: [PATCH 357/968] Bumb python-homewizard-energy to 2.1.2 (#101805) --- homeassistant/components/homewizard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/manifest.json b/homeassistant/components/homewizard/manifest.json index 8930ec90ebf..96507cb26e4 100644 --- a/homeassistant/components/homewizard/manifest.json +++ b/homeassistant/components/homewizard/manifest.json @@ -7,6 +7,6 @@ "iot_class": "local_polling", "loggers": ["homewizard_energy"], "quality_scale": "platinum", - "requirements": ["python-homewizard-energy==2.1.0"], + "requirements": ["python-homewizard-energy==2.1.2"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index c6dfbc1725b..bc9e1ccbd7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2122,7 +2122,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.1.0 +python-homewizard-energy==2.1.2 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 76ca48de574..7b2dc5ef348 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1584,7 +1584,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.1.0 +python-homewizard-energy==2.1.2 # homeassistant.components.izone python-izone==1.2.9 From 1a7601ebbeca4da377cd048e0147ab46eb3c5c17 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 13:04:40 +0200 Subject: [PATCH 358/968] Remove NONE_SENTINEL in favor of optional select in template (#101279) --- .../components/template/config_flow.py | 76 +++++-------------- .../components/template/strings.json | 3 - 2 files changed, 20 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index c361b4c42cc..cd6b7c8937f 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -37,8 +37,6 @@ from .const import DOMAIN from .sensor import async_create_preview_sensor from .template_entity import TemplateEntity -NONE_SENTINEL = "none" - def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: """Generate schema.""" @@ -48,71 +46,50 @@ def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: schema = { vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted( - [cls.value for cls in BinarySensorDeviceClass], - key=str.casefold, - ), - ], + options=[cls.value for cls in BinarySensorDeviceClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="binary_sensor_device_class", + sort=True, ), ) } if domain == Platform.SENSOR: schema = { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_UNIT_OF_MEASUREMENT): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted( - { - str(unit) - for units in DEVICE_CLASS_UNITS.values() - for unit in units - if unit is not None - }, - key=str.casefold, - ), - ], + options=list( + { + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + } + ), mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_unit_of_measurement", custom_value=True, + sort=True, ), ), - vol.Optional( - CONF_DEVICE_CLASS, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( options=[ - NONE_SENTINEL, - *sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ], - key=str.casefold, - ), + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_device_class", + sort=True, ), ), - vol.Optional( - CONF_STATE_CLASS, default=NONE_SENTINEL - ): selector.SelectSelector( + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[ - NONE_SENTINEL, - *sorted([cls.value for cls in SensorStateClass]), - ], + options=[cls.value for cls in SensorStateClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="sensor_state_class", + sort=True, ), ), } @@ -144,15 +121,6 @@ async def choose_options_step(options: dict[str, Any]) -> str: return cast(str, options["template_type"]) -def _strip_sentinel(options: dict[str, Any]) -> None: - """Convert sentinel to None.""" - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if key not in options: - continue - if options[key] == NONE_SENTINEL: - options.pop(key) - - def _validate_unit(options: dict[str, Any]) -> None: """Validate unit of measurement.""" if ( @@ -218,8 +186,6 @@ def validate_user_input( user_input: dict[str, Any], ) -> dict[str, Any]: """Add template type to user input.""" - if template_type in (Platform.BINARY_SENSOR, Platform.SENSOR): - _strip_sentinel(user_input) if template_type == Platform.SENSOR: _validate_unit(user_input) _validate_state_class(user_input) @@ -316,7 +282,6 @@ def ws_start_preview( errors[key.schema] = str(ex.msg) if domain == Platform.SENSOR: - _strip_sentinel(user_input) try: _validate_unit(user_input) except vol.Invalid as ex: @@ -386,7 +351,6 @@ def ws_start_preview( ) return - _strip_sentinel(msg["user_input"]) preview_entity = CREATE_PREVIEW_ENTITY[template_type](hass, name, msg["user_input"]) preview_entity.hass = hass preview_entity.registry_entry = entity_registry_entry diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index a0ee31126cd..8e7dbaade97 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -51,7 +51,6 @@ "selector": { "binary_sensor_device_class": { "options": { - "none": "[%key:component::template::selector::sensor_device_class::options::none%]", "battery": "[%key:component::binary_sensor::entity_component::battery::name%]", "battery_charging": "[%key:component::binary_sensor::entity_component::battery_charging::name%]", "carbon_monoxide": "[%key:component::binary_sensor::entity_component::carbon_monoxide::name%]", @@ -83,7 +82,6 @@ }, "sensor_device_class": { "options": { - "none": "No device class", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", "aqi": "[%key:component::sensor::entity_component::aqi::name%]", "atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]", @@ -137,7 +135,6 @@ }, "sensor_state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" From f116e83b622ab0a4a51a12c10f4f6e175b43449c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 11 Oct 2023 06:06:10 -0500 Subject: [PATCH 359/968] Add update entity for Plex Media Server (#101682) --- homeassistant/components/plex/const.py | 4 +- homeassistant/components/plex/update.py | 76 ++++++++++++ tests/components/plex/conftest.py | 21 ++++ .../components/plex/fixtures/release_new.xml | 4 + .../fixtures/release_new_not_updatable.xml | 4 + .../plex/fixtures/release_nochange.xml | 4 + tests/components/plex/test_update.py | 111 ++++++++++++++++++ 7 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/plex/update.py create mode 100644 tests/components/plex/fixtures/release_new.xml create mode 100644 tests/components/plex/fixtures/release_new_not_updatable.xml create mode 100644 tests/components/plex/fixtures/release_nochange.xml create mode 100644 tests/components/plex/test_update.py diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 7936cb6e6c3..30b59c73994 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -20,7 +20,9 @@ DEBOUNCE_TIMEOUT = 1 DISPATCHERS: Final = "dispatchers" GDM_DEBOUNCER: Final = "gdm_debouncer" GDM_SCANNER: Final = "gdm_scanner" -PLATFORMS = frozenset([Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR]) +PLATFORMS = frozenset( + [Platform.BUTTON, Platform.MEDIA_PLAYER, Platform.SENSOR, Platform.UPDATE] +) PLATFORMS_COMPLETED: Final = "platforms_completed" PLAYER_SOURCE = "player_source" SERVERS: Final = "servers" diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py new file mode 100644 index 00000000000..2d258bab900 --- /dev/null +++ b/homeassistant/components/plex/update.py @@ -0,0 +1,76 @@ +"""Representation of Plex updates.""" +import logging +from typing import Any + +from plexapi.exceptions import PlexApiException +import plexapi.server +import requests.exceptions + +from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_SERVER_IDENTIFIER +from .helpers import get_plex_server + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Plex media_player from a config entry.""" + server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + server = get_plex_server(hass, server_id) + plex_server = server.plex_server + can_update = await hass.async_add_executor_job(plex_server.canInstallUpdate) + async_add_entities([PlexUpdate(plex_server, can_update)], update_before_add=True) + + +class PlexUpdate(UpdateEntity): + """Representation of a Plex server update entity.""" + + _attr_supported_features = UpdateEntityFeature.RELEASE_NOTES + _release_notes: str | None = None + + def __init__( + self, plex_server: plexapi.server.PlexServer, can_update: bool + ) -> None: + """Initialize the Update entity.""" + self.plex_server = plex_server + self._attr_name = f"Plex Media Server ({plex_server.friendlyName})" + self._attr_unique_id = plex_server.machineIdentifier + if can_update: + self._attr_supported_features |= UpdateEntityFeature.INSTALL + + def update(self) -> None: + """Update sync attributes.""" + self._attr_installed_version = self.plex_server.version + try: + if (release := self.plex_server.checkForUpdate()) is None: + return + except (requests.exceptions.RequestException, PlexApiException): + _LOGGER.debug("Polling update sensor failed, will try again") + return + self._attr_latest_version = release.version + if release.fixed: + self._release_notes = "\n".join( + f"* {line}" for line in release.fixed.split("\n") + ) + else: + self._release_notes = None + + def release_notes(self) -> str | None: + """Return release notes for the available upgrade.""" + return self._release_notes + + def install(self, version: str | None, backup: bool, **kwargs: Any) -> None: + """Install an update.""" + try: + self.plex_server.installUpdate() + except (requests.exceptions.RequestException, PlexApiException) as exc: + raise HomeAssistantError(str(exc)) from exc diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 78a3b7387ea..92818633df4 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -396,6 +396,24 @@ def hubs_music_library_fixture(): return load_fixture("plex/hubs_library_section.xml") +@pytest.fixture(name="update_check_nochange", scope="session") +def update_check_fixture_nochange() -> str: + """Load a no-change update resource payload and return it.""" + return load_fixture("plex/release_nochange.xml") + + +@pytest.fixture(name="update_check_new", scope="session") +def update_check_fixture_new() -> str: + """Load a changed update resource payload and return it.""" + return load_fixture("plex/release_new.xml") + + +@pytest.fixture(name="update_check_new_not_updatable", scope="session") +def update_check_fixture_new_not_updatable() -> str: + """Load a changed update resource payload (not updatable) and return it.""" + return load_fixture("plex/release_new_not_updatable.xml") + + @pytest.fixture(name="entry") async def mock_config_entry(): """Return the default mocked config entry.""" @@ -452,6 +470,7 @@ def mock_plex_calls( plex_server_clients, plex_server_default, security_token, + update_check_nochange, ): """Mock Plex API calls.""" requests_mock.get("https://plex.tv/api/users/", text=plextv_shared_users) @@ -519,6 +538,8 @@ def mock_plex_calls( requests_mock.get(f"{url}/playlists", text=playlists) requests_mock.get(f"{url}/playlists/500/items", text=playlist_500) requests_mock.get(f"{url}/security/token", text=security_token) + requests_mock.put(f"{url}/updater/check") + requests_mock.get(f"{url}/updater/status", text=update_check_nochange) @pytest.fixture diff --git a/tests/components/plex/fixtures/release_new.xml b/tests/components/plex/fixtures/release_new.xml new file mode 100644 index 00000000000..4fd2b1e99f4 --- /dev/null +++ b/tests/components/plex/fixtures/release_new.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_new_not_updatable.xml b/tests/components/plex/fixtures/release_new_not_updatable.xml new file mode 100644 index 00000000000..c83be0b964c --- /dev/null +++ b/tests/components/plex/fixtures/release_new_not_updatable.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/fixtures/release_nochange.xml b/tests/components/plex/fixtures/release_nochange.xml new file mode 100644 index 00000000000..788db7fd2ca --- /dev/null +++ b/tests/components/plex/fixtures/release_nochange.xml @@ -0,0 +1,4 @@ + + + + diff --git a/tests/components/plex/test_update.py b/tests/components/plex/test_update.py new file mode 100644 index 00000000000..ce50f67a0d9 --- /dev/null +++ b/tests/components/plex/test_update.py @@ -0,0 +1,111 @@ +"""Tests for update entities.""" +import pytest +import requests_mock + +from homeassistant.components.update import ( + DOMAIN as UPDATE_DOMAIN, + SCAN_INTERVAL as UPDATER_SCAN_INTERVAL, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, HomeAssistantError +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator + +UPDATE_ENTITY = "update.plex_media_server_plex_server_1" + + +async def test_plex_update( + hass: HomeAssistant, + entry: MockConfigEntry, + hass_ws_client: WebSocketGenerator, + mock_plex_server, + requests_mock: requests_mock.Mocker, + empty_payload: str, + update_check_new: str, + update_check_new_not_updatable: str, +) -> None: + """Test Plex update entity.""" + ws_client = await hass_ws_client(hass) + + assert hass.states.get(UPDATE_ENTITY).state == STATE_OFF + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] is None + + apply_mock = requests_mock.put("/updater/apply") + + # Failed updates + requests_mock.get("/updater/status", status_code=500) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + requests_mock.get("/updater/status", text=empty_payload) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + + # New release (not updatable) + requests_mock.get("/updater/status", text=update_check_new_not_updatable) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATER_SCAN_INTERVAL) + await hass.async_block_till_done() + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert not apply_mock.called + + # New release (updatable) + requests_mock.get("/updater/status", text=update_check_new) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(UPDATE_ENTITY).state == STATE_ON + + ws_client = await hass_ws_client(hass) + await ws_client.send_json( + { + "id": 1, + "type": "update/release_notes", + "entity_id": UPDATE_ENTITY, + } + ) + result = await ws_client.receive_json() + assert result["result"] == "* Summary of\n* release notes" + + # Successful upgrade request + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) + assert apply_mock.called_once + + # Failed upgrade request + requests_mock.put("/updater/apply", status_code=500) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_ENTITY_ID: UPDATE_ENTITY, + }, + blocking=True, + ) From 0b2b4867547b2a9aa3d93e69c2c16a602d7b8627 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:25:11 +0200 Subject: [PATCH 360/968] Update mypy to 1.6.0 (#101780) --- homeassistant/auth/mfa_modules/__init__.py | 4 ++-- homeassistant/auth/permissions/util.py | 2 +- homeassistant/auth/providers/__init__.py | 2 +- homeassistant/backports/functools.py | 2 +- homeassistant/components/bond/utils.py | 4 ++-- homeassistant/components/cloud/prefs.py | 12 ++++++------ homeassistant/components/doorbird/util.py | 2 +- homeassistant/components/emulated_hue/config.py | 2 +- homeassistant/components/isy994/sensor.py | 2 +- homeassistant/components/media_extractor/__init__.py | 2 +- homeassistant/components/mysensors/climate.py | 2 +- homeassistant/components/upcloud/__init__.py | 2 +- homeassistant/data_entry_flow.py | 4 ++-- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/template.py | 2 +- mypy.ini | 3 +-- requirements_test.txt | 2 +- script/hassfest/mypy_config.py | 9 +++++++-- 18 files changed, 32 insertions(+), 28 deletions(-) diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py index b89982127a0..aa28710d8c6 100644 --- a/homeassistant/auth/mfa_modules/__init__.py +++ b/homeassistant/auth/mfa_modules/__init__.py @@ -50,7 +50,7 @@ class MultiFactorAuthModule: Default is same as type """ - return self.config.get(CONF_ID, self.type) + return self.config.get(CONF_ID, self.type) # type: ignore[no-any-return] @property def type(self) -> str: @@ -60,7 +60,7 @@ class MultiFactorAuthModule: @property def name(self) -> str: """Return the name of the auth module.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] # Implement by extending class diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py index 7a1f102fdf3..402d43b7ab7 100644 --- a/homeassistant/auth/permissions/util.py +++ b/homeassistant/auth/permissions/util.py @@ -109,4 +109,4 @@ def test_all(policy: CategoryType, key: str) -> bool: if not isinstance(all_policy, dict): return bool(all_policy) - return all_policy.get(key, False) + return all_policy.get(key, False) # type: ignore[no-any-return] diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 64d25a813ba..7d74dd2dc26 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -67,7 +67,7 @@ class AuthProvider: @property def name(self) -> str: """Return the name of the auth provider.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) # type: ignore[no-any-return] @property def support_mfa(self) -> bool: diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index d8b26e38449..6271bb87d14 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -78,4 +78,4 @@ class cached_property(Generic[_T]): raise TypeError(msg) from None return val - __class_getitem__ = classmethod(GenericAlias) + __class_getitem__ = classmethod(GenericAlias) # type: ignore[var-annotated] diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index 3a161a74bc5..ade8fd0b91d 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -70,7 +70,7 @@ class BondDevice: @property def trust_state(self) -> bool: """Check if Trust State is turned on.""" - return self.props.get("trust_state", False) + return self.props.get("trust_state", False) # type: ignore[no-any-return] def has_action(self, action: str) -> bool: """Check to see if the device supports an actions.""" @@ -203,7 +203,7 @@ class BondHub: @property def make(self) -> str: """Return this hub make.""" - return self._version.get("make", BRIDGE_MAKE) + return self._version.get("make", BRIDGE_MAKE) # type: ignore[no-any-return] @property def name(self) -> str: diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 8b6f773e5d9..57179431574 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -197,7 +197,7 @@ class CloudPreferences: @property def alexa_report_state(self) -> bool: """Return if Alexa report state is enabled.""" - return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) + return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) # type: ignore[no-any-return] @property def alexa_default_expose(self) -> list[str] | None: @@ -210,7 +210,7 @@ class CloudPreferences: @property def alexa_entity_configs(self) -> dict[str, Any]: """Return Alexa Entity configurations.""" - return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) + return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) # type: ignore[no-any-return] @property def alexa_settings_version(self) -> int: @@ -227,7 +227,7 @@ class CloudPreferences: @property def google_report_state(self) -> bool: """Return if Google report state is enabled.""" - return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) + return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) # type: ignore[no-any-return] @property def google_secure_devices_pin(self) -> str | None: @@ -237,7 +237,7 @@ class CloudPreferences: @property def google_entity_configs(self) -> dict[str, dict[str, Any]]: """Return Google Entity configurations.""" - return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) + return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) # type: ignore[no-any-return] @property def google_settings_version(self) -> int: @@ -262,12 +262,12 @@ class CloudPreferences: @property def cloudhooks(self) -> dict[str, Any]: """Return the published cloud webhooks.""" - return self._prefs.get(PREF_CLOUDHOOKS, {}) + return self._prefs.get(PREF_CLOUDHOOKS, {}) # type: ignore[no-any-return] @property def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" - return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) + return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) # type: ignore[no-any-return] async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" diff --git a/homeassistant/components/doorbird/util.py b/homeassistant/components/doorbird/util.py index b3b62a4985a..c307a125b8d 100644 --- a/homeassistant/components/doorbird/util.py +++ b/homeassistant/components/doorbird/util.py @@ -11,7 +11,7 @@ from .models import DoorBirdData def get_mac_address_from_door_station_info(door_station_info: dict[str, Any]) -> str: """Get the mac address depending on the device type.""" - return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) + return door_station_info.get("PRIMARY_MAC_ADDR", door_station_info["WIFI_MAC_ADDR"]) # type: ignore[no-any-return] def get_door_station_by_token( diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index 379f0bec9d7..069fc3177d6 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -204,7 +204,7 @@ class Config: ): return self.entities[state.entity_id][CONF_ENTITY_NAME] - return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) + return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) # type: ignore[no-any-return] @cache # pylint: disable=method-cache-max-size-none def get_exposed_states(self) -> list[State]: diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 1a160024a65..9e39f5d04e4 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -186,7 +186,7 @@ class ISYSensorEntity(ISYNodeEntity, SensorEntity): # Check if this is a known index pair UOM if isinstance(uom, dict): - return uom.get(value, value) + return uom.get(value, value) # type: ignore[no-any-return] if uom in (UOM_INDEX, UOM_ON_OFF): return cast(str, self.target.formatted) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index 328871cf78c..39ce1f7a3bd 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -87,7 +87,7 @@ class MediaExtractor: def get_entities(self) -> list[str]: """Return list of entities.""" - return self.call_data.get(ATTR_ENTITY_ID, []) + return self.call_data.get(ATTR_ENTITY_ID, []) # type: ignore[no-any-return] def extract_and_send(self) -> None: """Extract exact stream format for each entity_id and play it.""" diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py index e9d4502242e..d532135304a 100644 --- a/homeassistant/components/mysensors/climate.py +++ b/homeassistant/components/mysensors/climate.py @@ -144,7 +144,7 @@ class MySensorsHVAC(mysensors.device.MySensorsChildEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return current operation ie. heat, cool, idle.""" - return self._values.get(self.value_type, HVACMode.HEAT) + return self._values.get(self.value_type, HVACMode.HEAT) # type: ignore[no-any-return] @property def fan_mode(self) -> str | None: diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py index 174d35f07e0..a2554858fef 100644 --- a/homeassistant/components/upcloud/__init__.py +++ b/homeassistant/components/upcloud/__init__.py @@ -211,7 +211,7 @@ class UpCloudServerEntity(CoordinatorEntity[UpCloudDataUpdateCoordinator]): def is_on(self) -> bool: """Return true if the server is on.""" try: - return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON + return STATE_MAP.get(self._server.state, self._server.state) == STATE_ON # type: ignore[no-any-return] except AttributeError: return False diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e22d4229511..545b799c467 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -468,12 +468,12 @@ class FlowHandler: @property def source(self) -> str | None: """Source that initialized the flow.""" - return self.context.get("source", None) + return self.context.get("source", None) # type: ignore[no-any-return] @property def show_advanced_options(self) -> bool: """If we should show advanced options.""" - return self.context.get("show_advanced_options", False) + return self.context.get("show_advanced_options", False) # type: ignore[no-any-return] def add_suggested_values_to_schema( self, data_schema: vol.Schema, suggested_values: Mapping[str, Any] | None diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index f0a05f7aded..4bb3e5ef5bd 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -161,7 +161,7 @@ def get_supported_features(hass: HomeAssistant, entity_id: str) -> int: First try the statemachine, then entity registry. """ if state := hass.states.get(entity_id): - return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + return state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) # type: ignore[no-any-return] entity_registry = er.async_get(hass) if not (entry := entity_registry.async_get(entity_id)): diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b0754c13c7c..26b0674a351 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2513,7 +2513,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["expand"] = hassfunction(expand) self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = hassfunction(closest_filter) + self.filters["closest"] = hassfunction(closest_filter) # type: ignore[arg-type] self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) self.tests["is_hidden_entity"] = hassfunction( diff --git a/mypy.ini b/mypy.ini index 94ad7cc018b..435a9f5f2ff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,7 +7,6 @@ python_version = 3.11 plugins = pydantic.mypy show_error_codes = true follow_imports = silent -ignore_missing_imports = true local_partial_types = true strict_equality = true no_implicit_optional = true @@ -16,7 +15,7 @@ warn_redundant_casts = true warn_unused_configs = true warn_unused_ignores = true enable_error_code = ignore-without-code, redundant-self, truthy-iterable -disable_error_code = annotation-unchecked +disable_error_code = annotation-unchecked, import-not-found, import-untyped extra_checks = false check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_test.txt b/requirements_test.txt index d583ad5cc21..47d8fe1dcfe 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.0 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.5.1 +mypy==1.6.0 pre-commit==3.4.0 pydantic==1.10.12 pylint==3.0.1 diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 779d76078d6..5513105fa17 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -35,7 +35,6 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "show_error_codes": "true", "follow_imports": "silent", # Enable some checks globally. - "ignore_missing_imports": "true", "local_partial_types": "true", "strict_equality": "true", "no_implicit_optional": "true", @@ -50,7 +49,13 @@ GENERAL_SETTINGS: Final[dict[str, str]] = { "truthy-iterable", ] ), - "disable_error_code": ", ".join(["annotation-unchecked"]), + "disable_error_code": ", ".join( + [ + "annotation-unchecked", + "import-not-found", + "import-untyped", + ] + ), # Impractical in real code # E.g. this breaks passthrough ParamSpec typing with Concatenate "extra_checks": "false", From 87c82fb00f3d35928ed3f7ac50fec979ed6fab8a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 13:29:42 +0200 Subject: [PATCH 361/968] Remove NONE_SENTINEL in favor of optional select in workday (#101280) --- .../components/workday/config_flow.py | 44 +++++++++---------- homeassistant/components/workday/repairs.py | 10 ++--- tests/components/workday/test_config_flow.py | 19 -------- tests/components/workday/test_repairs.py | 4 +- 4 files changed, 26 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 6b2ecf2298a..3a4e381792e 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -43,23 +43,23 @@ from .const import ( LOGGER, ) -NONE_SENTINEL = "none" - def add_province_to_schema( schema: vol.Schema, - country: str, + country: str | None, ) -> vol.Schema: """Update schema with province from country.""" + if not country: + return schema + all_countries = list_supported_countries() if not all_countries.get(country): return schema - province_list = [NONE_SENTINEL, *all_countries[country]] add_schema = { - vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=province_list, + options=all_countries[country], mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) @@ -90,7 +90,7 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: raise AddDatesError("Incorrect date") year: int = dt_util.now().year - if country := user_input[CONF_COUNTRY]: + if country := user_input.get(CONF_COUNTRY): cls = country_holidays(country) obj_holidays = country_holidays( country=country, @@ -113,9 +113,9 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: DATA_SCHEMA_SETUP = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Optional(CONF_COUNTRY, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_COUNTRY): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL, *list(list_supported_countries())], + options=list(list_supported_countries()), mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_COUNTRY, ) @@ -202,11 +202,6 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): if user_input is not None: combined_input: dict[str, Any] = {**self.data, **user_input} - if combined_input.get(CONF_COUNTRY, NONE_SENTINEL) == NONE_SENTINEL: - combined_input[CONF_COUNTRY] = None - if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: - combined_input[CONF_PROVINCE] = None - try: await self.hass.async_add_executor_job( validate_custom_dates, combined_input @@ -221,13 +216,13 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors["remove_holidays"] = "remove_holiday_range_error" abort_match = { - CONF_COUNTRY: combined_input[CONF_COUNTRY], + CONF_COUNTRY: combined_input.get(CONF_COUNTRY), CONF_EXCLUDES: combined_input[CONF_EXCLUDES], CONF_OFFSET: combined_input[CONF_OFFSET], CONF_WORKDAYS: combined_input[CONF_WORKDAYS], CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], - CONF_PROVINCE: combined_input[CONF_PROVINCE], + CONF_PROVINCE: combined_input.get(CONF_PROVINCE), } LOGGER.debug("abort_check in options with %s", combined_input) self._async_abort_entries_match(abort_match) @@ -242,7 +237,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data[CONF_COUNTRY] + add_province_to_schema, DATA_SCHEMA_OPT, self.data.get(CONF_COUNTRY) ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( @@ -251,7 +246,7 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, description_placeholders={ "name": self.data[CONF_NAME], - "country": self.data[CONF_COUNTRY], + "country": self.data.get(CONF_COUNTRY), }, ) @@ -267,8 +262,9 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): if user_input is not None: combined_input: dict[str, Any] = {**self.options, **user_input} - if combined_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: - combined_input[CONF_PROVINCE] = None + if CONF_PROVINCE not in user_input: + # Province not present, delete old value (if present) too + combined_input.pop(CONF_PROVINCE, None) try: await self.hass.async_add_executor_job( @@ -287,13 +283,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): try: self._async_abort_entries_match( { - CONF_COUNTRY: self._config_entry.options[CONF_COUNTRY], + CONF_COUNTRY: self._config_entry.options.get(CONF_COUNTRY), CONF_EXCLUDES: combined_input[CONF_EXCLUDES], CONF_OFFSET: combined_input[CONF_OFFSET], CONF_WORKDAYS: combined_input[CONF_WORKDAYS], CONF_ADD_HOLIDAYS: combined_input[CONF_ADD_HOLIDAYS], CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], - CONF_PROVINCE: combined_input[CONF_PROVINCE], + CONF_PROVINCE: combined_input.get(CONF_PROVINCE), } ) except AbortFlow as err: @@ -302,7 +298,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY] + add_province_to_schema, DATA_SCHEMA_OPT, self.options.get(CONF_COUNTRY) ) new_schema = self.add_suggested_values_to_schema( @@ -315,7 +311,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): errors=errors, description_placeholders={ "name": self.options[CONF_NAME], - "country": self.options[CONF_COUNTRY], + "country": self.options.get(CONF_COUNTRY), }, ) diff --git a/homeassistant/components/workday/repairs.py b/homeassistant/components/workday/repairs.py index ff643ecc2cb..daafd0396b8 100644 --- a/homeassistant/components/workday/repairs.py +++ b/homeassistant/components/workday/repairs.py @@ -18,7 +18,6 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, ) -from .config_flow import NONE_SENTINEL from .const import CONF_PROVINCE @@ -75,9 +74,8 @@ class CountryFixFlow(RepairsFlow): self, user_input: dict[str, Any] | None = None ) -> data_entry_flow.FlowResult: """Handle the province step of a fix flow.""" - if user_input and user_input.get(CONF_PROVINCE): - if user_input.get(CONF_PROVINCE, NONE_SENTINEL) == NONE_SENTINEL: - user_input[CONF_PROVINCE] = None + if user_input is not None: + user_input.setdefault(CONF_PROVINCE, None) options = dict(self.entry.options) new_options = {**options, **user_input, CONF_COUNTRY: self.country} self.hass.config_entries.async_update_entry(self.entry, options=new_options) @@ -90,9 +88,9 @@ class CountryFixFlow(RepairsFlow): step_id="province", data_schema=vol.Schema( { - vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_PROVINCE): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL, *country_provinces], + options=country_provinces, mode=SelectSelectorMode.DROPDOWN, translation_key=CONF_PROVINCE, ) diff --git a/tests/components/workday/test_config_flow.py b/tests/components/workday/test_config_flow.py index cdc5f2a4011..89a001e0b55 100644 --- a/tests/components/workday/test_config_flow.py +++ b/tests/components/workday/test_config_flow.py @@ -9,7 +9,6 @@ from homeassistant.components.workday.const import ( CONF_COUNTRY, CONF_EXCLUDES, CONF_OFFSET, - CONF_PROVINCE, CONF_REMOVE_HOLIDAYS, CONF_WORKDAYS, DEFAULT_EXCLUDES, @@ -50,7 +49,6 @@ async def test_form(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: [], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -65,7 +63,6 @@ async def test_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, } @@ -81,7 +78,6 @@ async def test_form_no_country(hass: HomeAssistant) -> None: result["flow_id"], { CONF_NAME: "Workday Sensor", - CONF_COUNTRY: "none", }, ) await hass.async_block_till_done() @@ -101,13 +97,11 @@ async def test_form_no_country(hass: HomeAssistant) -> None: assert result3["title"] == "Workday Sensor" assert result3["options"] == { "name": "Workday Sensor", - "country": None, "excludes": ["sat", "sun", "holiday"], "days_offset": 0, "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, } @@ -149,7 +143,6 @@ async def test_form_no_subdivision(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, } @@ -166,7 +159,6 @@ async def test_options_form(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) @@ -221,7 +213,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-xx-12"], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -235,7 +226,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Does not exist"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -250,7 +240,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["Weihnachtstag"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -265,7 +254,6 @@ async def test_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12"], "remove_holidays": ["Weihnachtstag"], - "province": None, } @@ -282,7 +270,6 @@ async def test_options_form_incorrect_dates(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) @@ -383,7 +370,6 @@ async def test_options_form_abort_duplicate(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": "none", }, ) @@ -415,7 +401,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-30,2022-12-32"], CONF_REMOVE_HOLIDAYS: [], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -429,7 +414,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-32"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -444,7 +428,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: CONF_WORKDAYS: DEFAULT_WORKDAYS, CONF_ADD_HOLIDAYS: ["2022-12-12", "2022-12-01,2022-12-10"], CONF_REMOVE_HOLIDAYS: ["2022-12-25", "2022-12-30,2022-12-31"], - CONF_PROVINCE: "none", }, ) await hass.async_block_till_done() @@ -459,7 +442,6 @@ async def test_form_incorrect_date_range(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": ["2022-12-12", "2022-12-01,2022-12-10"], "remove_holidays": ["2022-12-25", "2022-12-30,2022-12-31"], - "province": None, } @@ -476,7 +458,6 @@ async def test_options_form_incorrect_date_ranges(hass: HomeAssistant) -> None: "workdays": ["mon", "tue", "wed", "thu", "fri"], "add_holidays": [], "remove_holidays": [], - "province": None, }, ) diff --git a/tests/components/workday/test_repairs.py b/tests/components/workday/test_repairs.py index 38b2142dfb7..d1920b7dc26 100644 --- a/tests/components/workday/test_repairs.py +++ b/tests/components/workday/test_repairs.py @@ -126,7 +126,7 @@ async def test_bad_country_none( data = await resp.json() url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "none"}) + resp = await client.post(url, json={}) assert resp.status == HTTPStatus.OK data = await resp.json() @@ -303,7 +303,7 @@ async def test_bad_province_none( assert data["step_id"] == "province" url = RepairsFlowResourceView.url.format(flow_id=flow_id) - resp = await client.post(url, json={"province": "none"}) + resp = await client.post(url, json={}) assert resp.status == HTTPStatus.OK data = await resp.json() From 183397e201132e90a26c6b7ed73029dc4e07057c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:32:11 +0200 Subject: [PATCH 362/968] Add MariaDB 10.11.2 to CI (#101807) --- .github/workflows/ci.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ba4a37fda14..c7e7d5642e8 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -46,9 +46,11 @@ env: # - 10.6.10 is the version currently shipped with the Add-on (as of 31 Jan 2023) # 10.10 is the latest short-term-support # - 10.10.3 is the latest (as of 6 Feb 2023) + # 10.11 is the latest long-term-support + # - 10.11.2 is the version currently shipped with Synology (as of 11 Oct 2023) # mysql 8.0.32 does not always behave the same as MariaDB # and some queries that work on MariaDB do not work on MySQL - MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mysql:8.0.32']" + MARIADB_VERSIONS: "['mariadb:10.3.32','mariadb:10.6.10','mariadb:10.10.3','mariadb:10.11.2','mysql:8.0.32']" # 12 is the oldest supported version # - 12.14 is the latest (as of 9 Feb 2023) # 15 is the latest version From f61627ea08de6ecd8c7d98618556c777396ef852 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Wed, 11 Oct 2023 13:46:02 +0200 Subject: [PATCH 363/968] Bump bimmer_connected to 0.14.1 (#101789) Co-authored-by: rikroe --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../snapshots/test_diagnostics.ambr | 25 +++++++++++++++++++ 4 files changed, 28 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 0a9e9cac5af..d64541d73be 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.0"] + "requirements": ["bimmer-connected==0.14.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index bc9e1ccbd7d..a98e49426c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -518,7 +518,7 @@ beautifulsoup4==4.12.2 bellows==0.36.5 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.0 +bimmer-connected==0.14.1 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7b2dc5ef348..74a586520d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -442,7 +442,7 @@ beautifulsoup4==4.12.2 bellows==0.36.5 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.0 +bimmer-connected==0.14.1 # homeassistant.components.bluetooth bleak-retry-connector==3.2.1 diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index 70224b41ff5..32405d93e6b 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -824,6 +824,11 @@ }), 'has_combustion_drivetrain': False, 'has_electric_drivetrain': True, + 'headunit': dict({ + 'headunit_type': 'MGU', + 'idrive_version': 'ID8', + 'software_version': '07/2021.00', + }), 'is_charging_plan_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': True, @@ -1685,6 +1690,11 @@ }), 'has_combustion_drivetrain': False, 'has_electric_drivetrain': True, + 'headunit': dict({ + 'headunit_type': 'MGU', + 'idrive_version': 'ID8', + 'software_version': '11/2021.70', + }), 'is_charging_plan_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, @@ -2318,6 +2328,11 @@ }), 'has_combustion_drivetrain': True, 'has_electric_drivetrain': False, + 'headunit': dict({ + 'headunit_type': 'MGU', + 'idrive_version': 'ID7', + 'software_version': '07/2021.70', + }), 'is_charging_plan_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, @@ -3015,6 +3030,11 @@ }), 'has_combustion_drivetrain': True, 'has_electric_drivetrain': True, + 'headunit': dict({ + 'headunit_type': 'NBT', + 'idrive_version': 'ID4', + 'software_version': '11/2021.10', + }), 'is_charging_plan_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, @@ -5346,6 +5366,11 @@ }), 'has_combustion_drivetrain': True, 'has_electric_drivetrain': True, + 'headunit': dict({ + 'headunit_type': 'NBT', + 'idrive_version': 'ID4', + 'software_version': '11/2021.10', + }), 'is_charging_plan_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, From 9107e166b478d0c7cab500bfbfeef3659144a4d8 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 11 Oct 2023 13:53:47 +0200 Subject: [PATCH 364/968] Adjust language slightly for philips_js strings (#101783) --- homeassistant/components/philips_js/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/philips_js/strings.json b/homeassistant/components/philips_js/strings.json index 19228e906d9..6c738a36df3 100644 --- a/homeassistant/components/philips_js/strings.json +++ b/homeassistant/components/philips_js/strings.json @@ -33,7 +33,7 @@ "allow_notify": "Allow notification service" }, "data_description": { - "allow_notify": "Allow the usage of data notification service on TV instead of periodic polling. This allow faster reaction to state changes on the TV, however, some TV's will stop responding when this activated due to firmware bugs." + "allow_notify": "Allow the usage of data notification service on TV instead of periodic polling. This allows faster reaction to state changes on the TV, however, some TV's will stop responding with this activated due to firmware bugs." } } } From 6771d4bda48823e52a9907134f7be9b46ec40ab5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 11 Oct 2023 14:44:52 +0200 Subject: [PATCH 365/968] Update aioqsw to v0.3.5 (#101809) --- homeassistant/components/qnap_qsw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 28e1ba7b8e4..76949b95cbd 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", "iot_class": "local_polling", "loggers": ["aioqsw"], - "requirements": ["aioqsw==0.3.4"] + "requirements": ["aioqsw==0.3.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a98e49426c2..53c06b9c18a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -327,7 +327,7 @@ aiopvpc==4.2.2 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.4 +aioqsw==0.3.5 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74a586520d0..1b81c1e7494 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -302,7 +302,7 @@ aiopvpc==4.2.2 aiopyarr==23.4.0 # homeassistant.components.qnap_qsw -aioqsw==0.3.4 +aioqsw==0.3.5 # homeassistant.components.recollect_waste aiorecollect==2023.09.0 From c66f0e33053d4d82a1c851bcec8a7158b895c4e1 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 11 Oct 2023 08:05:34 -0500 Subject: [PATCH 366/968] Fix Plex update module docstring (#101815) --- homeassistant/components/plex/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plex/update.py b/homeassistant/components/plex/update.py index 2d258bab900..e48c3a339d5 100644 --- a/homeassistant/components/plex/update.py +++ b/homeassistant/components/plex/update.py @@ -23,7 +23,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Plex media_player from a config entry.""" + """Set up Plex update entities from a config entry.""" server_id = config_entry.data[CONF_SERVER_IDENTIFIER] server = get_plex_server(hass, server_id) plex_server = server.plex_server From ddfad75eb7a6807a8b40490650540bca7e4e23b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Wed, 11 Oct 2023 16:09:56 +0200 Subject: [PATCH 367/968] Add basic auth to Blebox (#99320) Co-authored-by: Robert Resch --- homeassistant/components/blebox/__init__.py | 18 +++++++++++---- .../components/blebox/config_flow.py | 23 ++++++++++++++++--- homeassistant/components/blebox/helpers.py | 21 +++++++++++++++++ homeassistant/components/blebox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/blebox/test_config_flow.py | 15 ++++++++++++ tests/components/blebox/test_helpers.py | 20 ++++++++++++++++ 8 files changed, 93 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/blebox/helpers.py create mode 100644 tests/components/blebox/test_helpers.py diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index 371bb1aec40..d6c3cda7ef4 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -8,14 +8,20 @@ from blebox_uniapi.feature import Feature from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from .const import DEFAULT_SETUP_TIMEOUT, DOMAIN, PRODUCT +from .helpers import get_maybe_authenticated_session _LOGGER = logging.getLogger(__name__) @@ -36,12 +42,16 @@ _FeatureT = TypeVar("_FeatureT", bound=Feature) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" - websession = async_get_clientsession(hass) - host = entry.data[CONF_HOST] port = entry.data[CONF_PORT] + + username = entry.data.get(CONF_USERNAME) + password = entry.data.get(CONF_PASSWORD) + timeout = DEFAULT_SETUP_TIMEOUT + websession = get_maybe_authenticated_session(hass, password, username) + api_host = ApiHost(host, port, timeout, websession, hass.loop) try: diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index b43b1fb6b7f..31d1f6162d7 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -5,16 +5,22 @@ import logging from typing import Any from blebox_uniapi.box import Box -from blebox_uniapi.error import Error, UnsupportedBoxResponse, UnsupportedBoxVersion +from blebox_uniapi.error import ( + Error, + UnauthorizedRequest, + UnsupportedBoxResponse, + UnsupportedBoxVersion, +) from blebox_uniapi.session import ApiHost import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession +from . import get_maybe_authenticated_session from .const import ( ADDRESS_ALREADY_CONFIGURED, CANNOT_CONNECT, @@ -46,6 +52,8 @@ def create_schema(previous_input=None): { vol.Required(CONF_HOST, default=host): str, vol.Required(CONF_PORT, default=port): int, + vol.Inclusive(CONF_USERNAME, "auth"): str, + vol.Inclusive(CONF_PASSWORD, "auth"): str, } ) @@ -153,6 +161,9 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): addr = host_port(user_input) + username = user_input.get(CONF_USERNAME) + password = user_input.get(CONF_PASSWORD) + for entry in self._async_current_entries(): if addr == host_port(entry.data): host, port = addr @@ -160,7 +171,9 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): reason=ADDRESS_ALREADY_CONFIGURED, description_placeholders={"address": f"{host}:{port}"}, ) - websession = async_get_clientsession(hass) + + websession = get_maybe_authenticated_session(hass, password, username) + api_host = ApiHost(*addr, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER) try: product = await Box.async_from_host(api_host) @@ -169,6 +182,10 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.handle_step_exception( "user", ex, schema, *addr, UNSUPPORTED_VERSION, _LOGGER.debug ) + except UnauthorizedRequest as ex: + return self.handle_step_exception( + "user", ex, schema, *addr, CANNOT_CONNECT, _LOGGER.error + ) except Error as ex: return self.handle_step_exception( diff --git a/homeassistant/components/blebox/helpers.py b/homeassistant/components/blebox/helpers.py new file mode 100644 index 00000000000..82b8080b61d --- /dev/null +++ b/homeassistant/components/blebox/helpers.py @@ -0,0 +1,21 @@ +"""Blebox helpers.""" +from __future__ import annotations + +import aiohttp + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) + + +def get_maybe_authenticated_session( + hass: HomeAssistant, password: str | None, username: str | None +) -> aiohttp.ClientSession: + """Return proper session object.""" + if username and password: + auth = aiohttp.BasicAuth(login=username, password=password) + return async_create_clientsession(hass, auth=auth) + + return async_get_clientsession(hass) diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index b639e28d698..3eaa6d04ed2 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/blebox", "iot_class": "local_polling", "loggers": ["blebox_uniapi"], - "requirements": ["blebox-uniapi==2.1.4"], + "requirements": ["blebox-uniapi==2.2.0"], "zeroconf": ["_bbxsrv._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 53c06b9c18a..f90c1655c55 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -530,7 +530,7 @@ bleak-retry-connector==3.2.1 bleak==0.21.1 # homeassistant.components.blebox -blebox-uniapi==2.1.4 +blebox-uniapi==2.2.0 # homeassistant.components.blink blinkpy==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b81c1e7494..bc85da22b06 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bleak-retry-connector==3.2.1 bleak==0.21.1 # homeassistant.components.blebox -blebox-uniapi==2.1.4 +blebox-uniapi==2.2.0 # homeassistant.components.blink blinkpy==0.21.0 diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 765f7af3f62..dafba61d77a 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -153,6 +153,21 @@ async def test_flow_with_unsupported_version( assert result["errors"] == {"base": "unsupported_version"} +async def test_flow_with_auth_failure(hass: HomeAssistant, product_class_mock) -> None: + """Test that config flow works.""" + with product_class_mock as products_class: + products_class.async_from_host = AsyncMock( + side_effect=blebox_uniapi.error.UnauthorizedRequest + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={config_flow.CONF_HOST: "172.2.3.4", config_flow.CONF_PORT: 80}, + ) + assert result["errors"] == {"base": "cannot_connect"} + + async def test_async_setup(hass: HomeAssistant) -> None: """Test async_setup (for coverage).""" assert await async_setup_component(hass, "blebox", {"host": "172.2.3.4"}) diff --git a/tests/components/blebox/test_helpers.py b/tests/components/blebox/test_helpers.py new file mode 100644 index 00000000000..bf355612f14 --- /dev/null +++ b/tests/components/blebox/test_helpers.py @@ -0,0 +1,20 @@ +"""Blebox helpers tests.""" + +from aiohttp.helpers import BasicAuth + +from homeassistant.components.blebox.helpers import get_maybe_authenticated_session +from homeassistant.core import HomeAssistant + + +async def test_get_maybe_authenticated_session_none(hass: HomeAssistant): + """Tests if session auth is None.""" + session = get_maybe_authenticated_session(hass=hass, username="", password="") + assert session.auth is None + + +async def test_get_maybe_authenticated_session_auth(hass: HomeAssistant): + """Tests if session have BasicAuth.""" + session = get_maybe_authenticated_session( + hass=hass, username="user", password="password" + ) + assert isinstance(session.auth, BasicAuth) From 7db2fdd68c661e410c37b207427130eb852caed2 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 17:36:15 +0200 Subject: [PATCH 368/968] Remove "none" in favor of optional select in derivate (#101312) --- homeassistant/components/derivative/config_flow.py | 3 +-- homeassistant/components/derivative/sensor.py | 6 +----- tests/components/derivative/test_config_flow.py | 6 ------ 3 files changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/derivative/config_flow.py b/homeassistant/components/derivative/config_flow.py index 726d7616aff..92fff3730a9 100644 --- a/homeassistant/components/derivative/config_flow.py +++ b/homeassistant/components/derivative/config_flow.py @@ -24,7 +24,6 @@ from .const import ( ) UNIT_PREFIXES = [ - selector.SelectOptionDict(value="none", label="none"), selector.SelectOptionDict(value="n", label="n (nano)"), selector.SelectOptionDict(value="µ", label="µ (micro)"), selector.SelectOptionDict(value="m", label="m (milli)"), @@ -52,7 +51,7 @@ OPTIONS_SCHEMA = vol.Schema( ), ), vol.Required(CONF_TIME_WINDOW): selector.DurationSelector(), - vol.Required(CONF_UNIT_PREFIX, default="none"): selector.SelectSelector( + vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( selector.SelectSelectorConfig(options=UNIT_PREFIXES), ), vol.Required(CONF_UNIT_TIME, default=UnitOfTime.HOURS): selector.SelectSelector( diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index ba77d2a3d4b..a9c15dfe25e 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -116,10 +116,6 @@ async def async_setup_entry( else: device_info = None - unit_prefix = config_entry.options[CONF_UNIT_PREFIX] - if unit_prefix == "none": - unit_prefix = None - derivative_sensor = DerivativeSensor( name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), @@ -127,7 +123,7 @@ async def async_setup_entry( time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), unique_id=config_entry.entry_id, unit_of_measurement=None, - unit_prefix=unit_prefix, + unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, ) diff --git a/tests/components/derivative/test_config_flow.py b/tests/components/derivative/test_config_flow.py index 5be99026c77..bc440723df2 100644 --- a/tests/components/derivative/test_config_flow.py +++ b/tests/components/derivative/test_config_flow.py @@ -33,7 +33,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1, "source": input_sensor_entity_id, "time_window": {"seconds": 0}, - "unit_prefix": "none", "unit_time": "min", }, ) @@ -47,7 +46,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "time_window": {"seconds": 0.0}, - "unit_prefix": "none", "unit_time": "min", } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,7 +57,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "round": 1.0, "source": "sensor.input", "time_window": {"seconds": 0.0}, - "unit_prefix": "none", "unit_time": "min", } assert config_entry.title == "My derivative" @@ -111,7 +108,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: user_input={ "round": 2.0, "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", }, ) @@ -121,7 +117,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: "round": 2.0, "source": "sensor.input", "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", } assert config_entry.data == {} @@ -130,7 +125,6 @@ async def test_options(hass: HomeAssistant, platform) -> None: "round": 2.0, "source": "sensor.input", "time_window": {"seconds": 10.0}, - "unit_prefix": "none", "unit_time": "h", } assert config_entry.title == "My derivative" From 1915fee9ba2e9bdd268b15e8aa25d942e36580b3 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 17:36:53 +0200 Subject: [PATCH 369/968] Remove "none" in favor of optional select in integration (#101396) --- homeassistant/components/integration/config_flow.py | 3 +-- homeassistant/components/integration/sensor.py | 10 +++------- tests/components/integration/test_config_flow.py | 3 --- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/integration/config_flow.py b/homeassistant/components/integration/config_flow.py index 90fc7195cec..0b1eda7201e 100644 --- a/homeassistant/components/integration/config_flow.py +++ b/homeassistant/components/integration/config_flow.py @@ -27,7 +27,6 @@ from .const import ( ) UNIT_PREFIXES = [ - selector.SelectOptionDict(value="none", label="none"), selector.SelectOptionDict(value="k", label="k (kilo)"), selector.SelectOptionDict(value="M", label="M (mega)"), selector.SelectOptionDict(value="G", label="G (giga)"), @@ -74,7 +73,7 @@ CONFIG_SCHEMA = vol.Schema( unit_of_measurement="decimals", ), ), - vol.Required(CONF_UNIT_PREFIX, default="none"): selector.SelectSelector( + vol.Optional(CONF_UNIT_PREFIX): selector.SelectSelector( selector.SelectSelectorConfig(options=UNIT_PREFIXES), ), vol.Required(CONF_UNIT_TIME, default=UnitOfTime.HOURS): selector.SelectSelector( diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 66a99b63681..909266c51d4 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -77,7 +77,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), - vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), + vol.Optional(CONF_UNIT_PREFIX): vol.In(UNIT_PREFIXES), vol.Optional(CONF_UNIT_TIME, default=UnitOfTime.HOURS): vol.In(UNIT_TIME), vol.Remove(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_METHOD, default=METHOD_TRAPEZOIDAL): vol.In( @@ -169,17 +169,13 @@ async def async_setup_entry( else: device_info = None - unit_prefix = config_entry.options[CONF_UNIT_PREFIX] - if unit_prefix == "none": - unit_prefix = None - integral = IntegrationSensor( integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, unique_id=config_entry.entry_id, - unit_prefix=unit_prefix, + unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, ) @@ -200,7 +196,7 @@ async def async_setup_platform( round_digits=config[CONF_ROUND_DIGITS], source_entity=config[CONF_SOURCE_SENSOR], unique_id=config.get(CONF_UNIQUE_ID), - unit_prefix=config[CONF_UNIT_PREFIX], + unit_prefix=config.get(CONF_UNIT_PREFIX), unit_time=config[CONF_UNIT_TIME], ) diff --git a/tests/components/integration/test_config_flow.py b/tests/components/integration/test_config_flow.py index aedfbc6e8bc..c92cf70b0c2 100644 --- a/tests/components/integration/test_config_flow.py +++ b/tests/components/integration/test_config_flow.py @@ -33,7 +33,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1, "source": input_sensor_entity_id, - "unit_prefix": "none", "unit_time": "min", }, ) @@ -47,7 +46,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1.0, "source": "sensor.input", - "unit_prefix": "none", "unit_time": "min", } assert len(mock_setup_entry.mock_calls) == 1 @@ -59,7 +57,6 @@ async def test_config_flow(hass: HomeAssistant, platform) -> None: "name": "My integration", "round": 1.0, "source": "sensor.input", - "unit_prefix": "none", "unit_time": "min", } assert config_entry.title == "My integration" From 952a17532f8aa19808c30bfb996de83149f2640d Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 17:38:29 +0200 Subject: [PATCH 370/968] Remove NONE_SENTINEL in favor of optional select in sql (#101309) --- homeassistant/components/sql/config_flow.py | 37 ++++++++------------- homeassistant/components/sql/strings.json | 2 -- tests/components/sql/test_config_flow.py | 3 -- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index bd0a6d30369..e00b1f8e402 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -32,7 +32,6 @@ from .util import resolve_db_url _LOGGER = logging.getLogger(__name__) -NONE_SENTINEL = "none" OPTIONS_SCHEMA: vol.Schema = vol.Schema( { @@ -51,32 +50,24 @@ OPTIONS_SCHEMA: vol.Schema = vol.Schema( vol.Optional( CONF_VALUE_TEMPLATE, ): selector.TemplateSelector(), - vol.Optional( - CONF_DEVICE_CLASS, - default=NONE_SENTINEL, - ): selector.SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ] - ), + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="device_class", + sort=True, ) ), - vol.Optional( - CONF_STATE_CLASS, - default=NONE_SENTINEL, - ): selector.SelectSelector( + vol.Optional(CONF_STATE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted([cls.value for cls in SensorStateClass]), + options=[cls.value for cls in SensorStateClass], mode=selector.SelectSelectorMode.DROPDOWN, translation_key="state_class", + sort=True, ) ), } @@ -179,9 +170,9 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template - if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + if device_class := user_input.get(CONF_DEVICE_CLASS): options[CONF_DEVICE_CLASS] = device_class - if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + if state_class := user_input.get(CONF_STATE_CLASS): options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation @@ -248,9 +239,9 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): options[CONF_UNIT_OF_MEASUREMENT] = uom if value_template := user_input.get(CONF_VALUE_TEMPLATE): options[CONF_VALUE_TEMPLATE] = value_template - if (device_class := user_input[CONF_DEVICE_CLASS]) != NONE_SENTINEL: + if device_class := user_input.get(CONF_DEVICE_CLASS): options[CONF_DEVICE_CLASS] = device_class - if (state_class := user_input[CONF_STATE_CLASS]) != NONE_SENTINEL: + if state_class := user_input.get(CONF_STATE_CLASS): options[CONF_STATE_CLASS] = state_class if db_url_for_validation != get_instance(self.hass).db_url: options[CONF_DB_URL] = db_url_for_validation diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 9ac8bd22027..3289dfd41ff 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -67,7 +67,6 @@ "selector": { "device_class": { "options": { - "none": "No device class", "date": "[%key:component::sensor::entity_component::date::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -121,7 +120,6 @@ }, "state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index 915394863ea..6517e319fe4 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -8,7 +8,6 @@ from sqlalchemy.exc import SQLAlchemyError from homeassistant import config_entries from homeassistant.components.recorder import Recorder from homeassistant.components.sensor.const import SensorDeviceClass, SensorStateClass -from homeassistant.components.sql.config_flow import NONE_SENTINEL from homeassistant.components.sql.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -669,8 +668,6 @@ async def test_device_state_class(recorder_mock: Recorder, hass: HomeAssistant) "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "device_class": NONE_SENTINEL, - "state_class": NONE_SENTINEL, }, ) await hass.async_block_till_done() From fd72ebd733cd9a120af97effe03fcd516c049fb0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Oct 2023 17:40:26 +0200 Subject: [PATCH 371/968] Mark entities field of scene.create service advanced (#101810) --- homeassistant/components/scene/services.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml index b9e12bcc8d7..543cefd5b9a 100644 --- a/homeassistant/components/scene/services.yaml +++ b/homeassistant/components/scene/services.yaml @@ -39,6 +39,7 @@ create: selector: text: entities: + advanced: true example: | light.tv_back_light: "on" light.ceiling: From dfea1c2b7c68abdcc39b87e12095dfaf6c9e87de Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 17:41:31 +0200 Subject: [PATCH 372/968] Remove NONE_SENTINEL in favor of optional select in scrape (#101278) Co-authored-by: Erik Montnemery --- .../components/scrape/config_flow.py | 48 +++++++------------ homeassistant/components/scrape/strings.json | 2 - tests/components/scrape/test_config_flow.py | 19 -------- 3 files changed, 18 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py index dc0254cc642..b4305b3948e 100644 --- a/homeassistant/components/scrape/config_flow.py +++ b/homeassistant/components/scrape/config_flow.py @@ -97,8 +97,6 @@ RESOURCE_SETUP = { vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(), } -NONE_SENTINEL = "none" - SENSOR_SETUP = { vol.Required(CONF_SELECT): TextSelector(), vol.Optional(CONF_INDEX, default=0): NumberSelector( @@ -106,45 +104,36 @@ SENSOR_SETUP = { ), vol.Optional(CONF_ATTRIBUTE): TextSelector(), vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), - vol.Required(CONF_DEVICE_CLASS, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] - + sorted( - [ - cls.value - for cls in SensorDeviceClass - if cls != SensorDeviceClass.ENUM - ] - ), + options=[ + cls.value for cls in SensorDeviceClass if cls != SensorDeviceClass.ENUM + ], mode=SelectSelectorMode.DROPDOWN, translation_key="device_class", + sort=True, ) ), - vol.Required(CONF_STATE_CLASS, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_STATE_CLASS): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]), + options=[cls.value for cls in SensorStateClass], mode=SelectSelectorMode.DROPDOWN, translation_key="state_class", + sort=True, ) ), - vol.Required(CONF_UNIT_OF_MEASUREMENT, default=NONE_SENTINEL): SelectSelector( + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( SelectSelectorConfig( - options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]), + options=[cls.value for cls in UnitOfTemperature], custom_value=True, mode=SelectSelectorMode.DROPDOWN, translation_key="unit_of_measurement", + sort=True, ) ), } -def _strip_sentinel(options: dict[str, Any]) -> None: - """Convert sentinel to None.""" - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if options[key] == NONE_SENTINEL: - options.pop(key) - - async def validate_rest_setup( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: @@ -171,7 +160,6 @@ async def validate_sensor_setup( # Standard behavior is to merge the result with the options. # In this case, we want to add a sub-item so we update the options directly. sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, []) - _strip_sentinel(user_input) sensors.append(user_input) return {} @@ -203,11 +191,7 @@ async def get_edit_sensor_suggested_values( ) -> dict[str, Any]: """Return suggested values for sensor editing.""" idx: int = handler.flow_state["_idx"] - suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx]) - for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT): - if not suggested_values.get(key): - suggested_values[key] = NONE_SENTINEL - return suggested_values + return dict(handler.options[SENSOR_DOMAIN][idx]) async def validate_sensor_edit( @@ -217,10 +201,14 @@ async def validate_sensor_edit( user_input[CONF_INDEX] = int(user_input[CONF_INDEX]) # Standard behavior is to merge the result with the options. - # In this case, we want to add a sub-item so we update the options directly. + # In this case, we want to add a sub-item so we update the options directly, + # including popping omitted optional schema items. idx: int = handler.flow_state["_idx"] handler.options[SENSOR_DOMAIN][idx].update(user_input) - _strip_sentinel(handler.options[SENSOR_DOMAIN][idx]) + for key in DATA_SCHEMA_EDIT_SENSOR.schema: + if isinstance(key, vol.Optional) and key not in user_input: + # Key not present, delete keys old value (if present) too + handler.options[SENSOR_DOMAIN][idx].pop(key, None) return {} diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index fc2d83dada4..45f48c8401e 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -133,7 +133,6 @@ "selector": { "device_class": { "options": { - "none": "No device class", "date": "[%key:component::sensor::entity_component::date::name%]", "duration": "[%key:component::sensor::entity_component::duration::name%]", "apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]", @@ -187,7 +186,6 @@ }, "state_class": { "options": { - "none": "No state class", "measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]", "total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]", "total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]" diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py index 9e1895f3a58..7dd2954f8c3 100644 --- a/tests/components/scrape/test_config_flow.py +++ b/tests/components/scrape/test_config_flow.py @@ -8,7 +8,6 @@ from homeassistant import config_entries from homeassistant.components.rest.data import DEFAULT_TIMEOUT from homeassistant.components.rest.schema import DEFAULT_METHOD from homeassistant.components.scrape import DOMAIN -from homeassistant.components.scrape.config_flow import NONE_SENTINEL from homeassistant.components.scrape.const import ( CONF_ENCODING, CONF_INDEX, @@ -71,9 +70,6 @@ async def test_form( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -132,9 +128,6 @@ async def test_form_with_post( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -226,9 +219,6 @@ async def test_flow_fails( CONF_NAME: "Current version", CONF_SELECT: ".current-version h1", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -350,9 +340,6 @@ async def test_options_add_remove_sensor_flow( CONF_NAME: "Template", CONF_SELECT: "template", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -480,9 +467,6 @@ async def test_options_edit_sensor_flow( user_input={ CONF_SELECT: "template", CONF_INDEX: 0.0, - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() @@ -646,9 +630,6 @@ async def test_sensor_options_remove_device_class( CONF_SELECT: ".current-temp h3", CONF_INDEX: 0.0, CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", - CONF_DEVICE_CLASS: NONE_SENTINEL, - CONF_STATE_CLASS: NONE_SENTINEL, - CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL, }, ) await hass.async_block_till_done() From 6c4ac712189c0a713ceff03f42f6fd9fcbe6a13b Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Wed, 11 Oct 2023 17:52:18 +0200 Subject: [PATCH 373/968] Remove "none" in favor of optional select in brottsplatskartan (#101311) --- homeassistant/components/brottsplatskartan/config_flow.py | 7 ++----- homeassistant/components/brottsplatskartan/const.py | 1 - homeassistant/components/brottsplatskartan/strings.json | 7 ------- tests/components/brottsplatskartan/test_config_flow.py | 5 +---- 4 files changed, 3 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/brottsplatskartan/config_flow.py b/homeassistant/components/brottsplatskartan/config_flow.py index 09d6cd96087..ac9a764179e 100644 --- a/homeassistant/components/brottsplatskartan/config_flow.py +++ b/homeassistant/components/brottsplatskartan/config_flow.py @@ -18,11 +18,10 @@ DATA_SCHEMA = vol.Schema( vol.Optional(CONF_LOCATION): selector.LocationSelector( selector.LocationSelectorConfig(radius=False, icon="") ), - vol.Optional(CONF_AREA, default="none"): selector.SelectSelector( + vol.Optional(CONF_AREA): selector.SelectSelector( selector.SelectSelectorConfig( options=AREAS, mode=selector.SelectSelectorMode.DROPDOWN, - translation_key="areas", ) ), } @@ -43,9 +42,7 @@ class BPKConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: latitude: float | None = None longitude: float | None = None - area: str | None = ( - user_input[CONF_AREA] if user_input[CONF_AREA] != "none" else None - ) + area: str | None = user_input.get(CONF_AREA) if area: name = f"{DEFAULT_NAME} {area}" diff --git a/homeassistant/components/brottsplatskartan/const.py b/homeassistant/components/brottsplatskartan/const.py index 8bd08f452f4..b53a39755a6 100644 --- a/homeassistant/components/brottsplatskartan/const.py +++ b/homeassistant/components/brottsplatskartan/const.py @@ -14,7 +14,6 @@ CONF_APP_ID = "app_id" DEFAULT_NAME = "Brottsplatskartan" AREAS = [ - "none", "Blekinge län", "Dalarnas län", "Gotlands län", diff --git a/homeassistant/components/brottsplatskartan/strings.json b/homeassistant/components/brottsplatskartan/strings.json index f10120f7884..bd8d5ad8dbe 100644 --- a/homeassistant/components/brottsplatskartan/strings.json +++ b/homeassistant/components/brottsplatskartan/strings.json @@ -15,12 +15,5 @@ } } } - }, - "selector": { - "areas": { - "options": { - "none": "No area" - } - } } } diff --git a/tests/components/brottsplatskartan/test_config_flow.py b/tests/components/brottsplatskartan/test_config_flow.py index efd259fa73c..f27139ad381 100644 --- a/tests/components/brottsplatskartan/test_config_flow.py +++ b/tests/components/brottsplatskartan/test_config_flow.py @@ -23,9 +23,7 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_AREA: "none", - }, + {}, ) await hass.async_block_till_done() @@ -51,7 +49,6 @@ async def test_form_location(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_AREA: "none", CONF_LOCATION: { CONF_LATITUDE: 59.32, CONF_LONGITUDE: 18.06, From 257686fcfeff4f382fbe932b66b4c9699654dc0c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 11 Oct 2023 12:21:32 -0500 Subject: [PATCH 374/968] Dynamic wake word loading for Wyoming (#101827) * Change supported_wake_words property to async method * Add test * Add timeout + test --------- Co-authored-by: Paulus Schoutsen --- .../components/wake_word/__init__.py | 20 +++++-- homeassistant/components/wyoming/wake_word.py | 20 +++++-- tests/components/assist_pipeline/conftest.py | 5 +- tests/components/wake_word/test_init.py | 35 ++++++++++-- tests/components/wyoming/test_wake_word.py | 57 ++++++++++++++++++- 5 files changed, 120 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/wake_word/__init__.py b/homeassistant/components/wake_word/__init__.py index 6c55bd8e7e7..8c8fb85b8b3 100644 --- a/homeassistant/components/wake_word/__init__.py +++ b/homeassistant/components/wake_word/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import abstractmethod +import asyncio from collections.abc import AsyncIterable import logging from typing import final @@ -34,6 +35,8 @@ _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) +TIMEOUT_FETCH_WAKE_WORDS = 10 + @callback def async_default_entity(hass: HomeAssistant) -> str | None: @@ -86,9 +89,8 @@ class WakeWordDetectionEntity(RestoreEntity): """Return the state of the entity.""" return self.__last_detected - @property @abstractmethod - def supported_wake_words(self) -> list[WakeWord]: + async def get_supported_wake_words(self) -> list[WakeWord]: """Return a list of supported wake words.""" @abstractmethod @@ -133,8 +135,9 @@ class WakeWordDetectionEntity(RestoreEntity): vol.Required("entity_id"): cv.entity_domain(DOMAIN), } ) +@websocket_api.async_response @callback -def websocket_entity_info( +async def websocket_entity_info( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: """Get info about wake word entity.""" @@ -147,7 +150,16 @@ def websocket_entity_info( ) return + try: + async with asyncio.timeout(TIMEOUT_FETCH_WAKE_WORDS): + wake_words = await entity.get_supported_wake_words() + except asyncio.TimeoutError: + connection.send_error( + msg["id"], websocket_api.const.ERR_TIMEOUT, "Timeout fetching wake words" + ) + return + connection.send_result( msg["id"], - {"wake_words": entity.supported_wake_words}, + {"wake_words": wake_words}, ) diff --git a/homeassistant/components/wyoming/wake_word.py b/homeassistant/components/wyoming/wake_word.py index d4cbd9b9263..fce8bbf6327 100644 --- a/homeassistant/components/wyoming/wake_word.py +++ b/homeassistant/components/wyoming/wake_word.py @@ -13,7 +13,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN -from .data import WyomingService +from .data import WyomingService, load_wyoming_info from .error import WyomingError _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ async def async_setup_entry( service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - WyomingWakeWordProvider(config_entry, service), + WyomingWakeWordProvider(hass, config_entry, service), ] ) @@ -38,10 +38,12 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): def __init__( self, + hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService, ) -> None: """Set up provider.""" + self.hass = hass self.service = service wake_service = service.info.wake[0] @@ -52,9 +54,19 @@ class WyomingWakeWordProvider(wake_word.WakeWordDetectionEntity): self._attr_name = wake_service.name self._attr_unique_id = f"{config_entry.entry_id}-wake_word" - @property - def supported_wake_words(self) -> list[wake_word.WakeWord]: + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" + info = await load_wyoming_info( + self.service.host, self.service.port, retries=0, timeout=1 + ) + + if info is not None: + wake_service = info.wake[0] + self._supported_wake_words = [ + wake_word.WakeWord(id=ww.name, name=ww.description or ww.name) + for ww in wake_service.models + ] + return self._supported_wake_words async def _async_process_audio_stream( diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index cde2666c1ea..1a3144ee069 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -181,8 +181,7 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): url_path = "wake_word.test" _attr_name = "test" - @property - def supported_wake_words(self) -> list[wake_word.WakeWord]: + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] @@ -191,7 +190,7 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" if wake_word_id is None: - wake_word_id = self.supported_wake_words[0].id + wake_word_id = (await self.get_supported_wake_words())[0].id async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( diff --git a/tests/components/wake_word/test_init.py b/tests/components/wake_word/test_init.py index 5d1cc5a4b3f..6b147229d47 100644 --- a/tests/components/wake_word/test_init.py +++ b/tests/components/wake_word/test_init.py @@ -1,6 +1,9 @@ """Test wake_word component setup.""" +import asyncio from collections.abc import AsyncIterable, Generator +from functools import partial from pathlib import Path +from unittest.mock import patch from freezegun import freeze_time import pytest @@ -37,8 +40,7 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): url_path = "wake_word.test" _attr_name = "test" - @property - def supported_wake_words(self) -> list[wake_word.WakeWord]: + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" return [ wake_word.WakeWord(id="test_ww", name="Test Wake Word"), @@ -50,7 +52,7 @@ class MockProviderEntity(wake_word.WakeWordDetectionEntity): ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" if wake_word_id is None: - wake_word_id = self.supported_wake_words[0].id + wake_word_id = (await self.get_supported_wake_words())[0].id async for _chunk, timestamp in stream: if timestamp >= 2000: @@ -294,7 +296,7 @@ async def test_list_wake_words_unknown_entity( setup: MockProviderEntity, hass_ws_client: WebSocketGenerator, ) -> None: - """Test that the list_wake_words websocket command works.""" + """Test that the list_wake_words websocket command handles unknown entity.""" client = await hass_ws_client(hass) await client.send_json( { @@ -308,3 +310,28 @@ async def test_list_wake_words_unknown_entity( assert not msg["success"] assert msg["error"] == {"code": "not_found", "message": "Entity not found"} + + +async def test_list_wake_words_timeout( + hass: HomeAssistant, + setup: MockProviderEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test that the list_wake_words websocket command handles unknown entity.""" + client = await hass_ws_client(hass) + + with patch.object( + setup, "get_supported_wake_words", partial(asyncio.sleep, 1) + ), patch("homeassistant.components.wake_word.TIMEOUT_FETCH_WAKE_WORDS", 0): + await client.send_json( + { + "id": 5, + "type": "wake_word/info", + "entity_id": setup.entity_id, + } + ) + + msg = await client.receive_json() + + assert not msg["success"] + assert msg["error"] == {"code": "timeout", "message": "Timeout fetching wake words"} diff --git a/tests/components/wyoming/test_wake_word.py b/tests/components/wyoming/test_wake_word.py index b3c09d4e816..36a6daf0452 100644 --- a/tests/components/wyoming/test_wake_word.py +++ b/tests/components/wyoming/test_wake_word.py @@ -6,12 +6,13 @@ from unittest.mock import patch from syrupy.assertion import SnapshotAssertion from wyoming.asr import Transcript +from wyoming.info import Info, WakeModel, WakeProgram from wyoming.wake import Detection from homeassistant.components import wake_word from homeassistant.core import HomeAssistant -from . import MockAsyncTcpClient +from . import TEST_ATTR, MockAsyncTcpClient async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: @@ -24,7 +25,7 @@ async def test_support(hass: HomeAssistant, init_wyoming_wake_word) -> None: ) assert entity is not None - assert entity.supported_wake_words == [ + assert (await entity.get_supported_wake_words()) == [ wake_word.WakeWord(id="Test Model", name="Test Model") ] @@ -157,3 +158,55 @@ async def test_detect_message_with_wrong_wake_word( result = await entity.async_process_audio_stream(audio_stream(), "my-wake-word") assert result is None + + +async def test_dynamic_wake_word_info( + hass: HomeAssistant, init_wyoming_wake_word +) -> None: + """Test that supported wake words are loaded dynamically.""" + entity = wake_word.async_get_wake_word_detection_entity( + hass, "wake_word.test_wake_word" + ) + assert entity is not None + + # Original info + assert (await entity.get_supported_wake_words()) == [ + wake_word.WakeWord("Test Model", "Test Model") + ] + + new_info = Info( + wake=[ + WakeProgram( + name="dynamic", + description="Dynamic Wake Word", + installed=True, + attribution=TEST_ATTR, + models=[ + WakeModel( + name="ww1", + description="Wake Word 1", + installed=True, + attribution=TEST_ATTR, + languages=[], + ), + WakeModel( + name="ww2", + description="Wake Word 2", + installed=True, + attribution=TEST_ATTR, + languages=[], + ), + ], + ) + ] + ) + + # Different Wyoming info will be fetched + with patch( + "homeassistant.components.wyoming.wake_word.load_wyoming_info", + return_value=new_info, + ): + assert (await entity.get_supported_wake_words()) == [ + wake_word.WakeWord("ww1", "Wake Word 1"), + wake_word.WakeWord("ww2", "Wake Word 2"), + ] From f0317f0d59ef193d826393d5cce50fe610a00010 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Wed, 11 Oct 2023 13:32:00 -0500 Subject: [PATCH 375/968] Close existing UDP server for ESPHome voice assistant (#101845) --- homeassistant/components/esphome/manager.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index dfd7376f4f4..41fd60af07d 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -327,7 +327,10 @@ class ESPHomeManager: ) -> int | None: """Start a voice assistant pipeline.""" if self.voice_assistant_udp_server is not None: - return None + _LOGGER.warning("Voice assistant UDP server was not stopped") + self.voice_assistant_udp_server.stop() + self.voice_assistant_udp_server.close() + self.voice_assistant_udp_server = None hass = self.hass self.voice_assistant_udp_server = VoiceAssistantUDPServer( From 6ce5f190c1fea8f1518051f442239e67f39b418e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 11 Oct 2023 20:45:20 -1000 Subject: [PATCH 376/968] Avoid duplicate property calls when writing sensor state (#101853) * Avoid duplicate attribute lookups when writing sensor state _numeric_state_expected would call self.device_class, self.state_class, self.native_unit_of_measurement, and self.suggested_display_precision a second time when the `state` path already had these values. * one more * avoid another --- homeassistant/components/sensor/__init__.py | 60 +++++++++++++-------- 1 file changed, 37 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 10bf976f012..0fa270bb03d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -150,6 +150,28 @@ class SensorEntityDescription(EntityDescription): unit_of_measurement: None = None # Type override, use native_unit_of_measurement +def _numeric_state_expected( + device_class: SensorDeviceClass | None, + state_class: SensorStateClass | str | None, + native_unit_of_measurement: str | None, + suggested_display_precision: int | None, +) -> bool: + """Return true if the sensor must be numeric.""" + # Note: the order of the checks needs to be kept aligned + # with the checks in `state` property. + if device_class in NON_NUMERIC_DEVICE_CLASSES: + return False + if ( + state_class is not None + or native_unit_of_measurement is not None + or suggested_display_precision is not None + ): + return True + # Sensors with custom device classes will have the device class + # converted to None and are not considered numeric + return device_class is not None + + class SensorEntity(Entity): """Base class for sensor entities.""" @@ -284,20 +306,12 @@ class SensorEntity(Entity): @property def _numeric_state_expected(self) -> bool: """Return true if the sensor must be numeric.""" - # Note: the order of the checks needs to be kept aligned - # with the checks in `state` property. - device_class = try_parse_enum(SensorDeviceClass, self.device_class) - if device_class in NON_NUMERIC_DEVICE_CLASSES: - return False - if ( - self.state_class is not None - or self.native_unit_of_measurement is not None - or self.suggested_display_precision is not None - ): - return True - # Sensors with custom device classes will have the device class - # converted to None and are not considered numeric - return device_class is not None + return _numeric_state_expected( + try_parse_enum(SensorDeviceClass, self.device_class), + self.state_class, + self.native_unit_of_measurement, + self.suggested_display_precision, + ) @property def options(self) -> list[str] | None: @@ -377,10 +391,8 @@ class SensorEntity(Entity): def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" if last_reset := self.last_reset: - if ( - self.state_class != SensorStateClass.TOTAL - and not self._last_reset_reported - ): + state_class = self.state_class + if state_class != SensorStateClass.TOTAL and not self._last_reset_reported: self._last_reset_reported = True report_issue = self._suggest_report_issue() # This should raise in Home Assistant Core 2022.5 @@ -393,11 +405,11 @@ class SensorEntity(Entity): ), self.entity_id, type(self), - self.state_class, + state_class, report_issue, ) - if self.state_class == SensorStateClass.TOTAL: + if state_class == SensorStateClass.TOTAL: return {ATTR_LAST_RESET: last_reset.isoformat()} return None @@ -470,9 +482,9 @@ class SensorEntity(Entity): native_unit_of_measurement = self.native_unit_of_measurement if ( - self.device_class == SensorDeviceClass.TEMPERATURE - and native_unit_of_measurement + native_unit_of_measurement in {UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT} + and self.device_class == SensorDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -590,7 +602,9 @@ class SensorEntity(Entity): # If the sensor has neither a device class, a state class, a unit of measurement # nor a precision then there are no further checks or conversions - if not self._numeric_state_expected: + if not _numeric_state_expected( + device_class, state_class, native_unit_of_measurement, suggested_precision + ): return value # From here on a numerical value is expected From 7fd89b29590c51f57aea59909b29c73fd8ccc025 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Thu, 12 Oct 2023 09:01:29 +0200 Subject: [PATCH 377/968] Add brake pads condition based service attributes for BMW (#101847) Add brake pads condition based service attributes Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/binary_sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index d3711a8f2e6..0e3750de085 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -30,6 +30,8 @@ _LOGGER = logging.getLogger(__name__) ALLOWED_CONDITION_BASED_SERVICE_KEYS = { "BRAKE_FLUID", + "BRAKE_PADS_FRONT", + "BRAKE_PADS_REAR", "EMISSION_CHECK", "ENGINE_OIL", "OIL", From 2276be275d98790f3b67aab7fdd55fc06cc3768f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 12 Oct 2023 00:18:34 -0700 Subject: [PATCH 378/968] Await set value function in ScreenLogic number entities (#101802) --- homeassistant/components/screenlogic/number.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index d3ed25f5570..a52e894c72b 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,5 +1,6 @@ """Support for a ScreenLogic number entity.""" -from collections.abc import Callable +import asyncio +from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging @@ -105,13 +106,13 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): ) -> None: """Initialize a ScreenLogic number entity.""" super().__init__(coordinator, entity_description) - if not callable( + if not asyncio.iscoroutinefunction( func := getattr(self.gateway, entity_description.set_value_name) ): raise TypeError( - f"set_value_name '{entity_description.set_value_name}' is not a callable" + f"set_value_name '{entity_description.set_value_name}' is not a coroutine" ) - self._set_value_func: Callable[..., bool] = func + self._set_value_func: Callable[..., Awaitable[bool]] = func self._set_value_args = entity_description.set_value_args self._attr_native_unit_of_measurement = get_ha_unit( self.entity_data.get(ATTR.UNIT) @@ -145,9 +146,12 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): data_key = data_path[-1] args[data_key] = self.coordinator.gateway.get_value(*data_path, strict=True) + # Current API requires int values for the currently supported numbers. + value = int(value) + args[self._data_key] = value - if self._set_value_func(*args.values()): + if await self._set_value_func(*args.values()): _LOGGER.debug("Set '%s' to %s", self._data_key, value) await self._async_refresh() else: From 5523e9947d82ac1479029468808e5745c84332b6 Mon Sep 17 00:00:00 2001 From: Justin Lindh Date: Thu, 12 Oct 2023 01:42:40 -0700 Subject: [PATCH 379/968] Bump Python-MyQ to v3.1.13 (#101852) --- homeassistant/components/myq/manifest.json | 2 +- pyproject.toml | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json index 5efcb8e1bb0..e924d06955b 100644 --- a/homeassistant/components/myq/manifest.json +++ b/homeassistant/components/myq/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["pkce", "pymyq"], - "requirements": ["python-myq==3.1.11"] + "requirements": ["python-myq==3.1.13"] } diff --git a/pyproject.toml b/pyproject.toml index 46865110200..508b2c06b9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -528,7 +528,7 @@ filterwarnings = [ "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated:DeprecationWarning:proto.datetime_helpers", # https://github.com/MatsNl/pyatag/issues/11 - v0.3.7.1 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pyatag.gateway", - # https://github.com/Python-MyQ/Python-MyQ - v3.1.11 + # https://github.com/Python-MyQ/Python-MyQ - v3.1.13 "ignore:datetime.*utcnow\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pymyq.(api|account)", # Wrong stacklevel # https://bugs.launchpad.net/beautifulsoup/+bug/2034451 diff --git a/requirements_all.txt b/requirements_all.txt index f90c1655c55..e5f479d9860 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.myq -python-myq==3.1.11 +python-myq==3.1.13 # homeassistant.components.mystrom python-mystrom==2.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc85da22b06..102a72f7612 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1602,7 +1602,7 @@ python-matter-server==3.7.0 python-miio==0.5.12 # homeassistant.components.myq -python-myq==3.1.11 +python-myq==3.1.13 # homeassistant.components.mystrom python-mystrom==2.2.0 From 830981ddd697c64da4375dad60a5e864fec01736 Mon Sep 17 00:00:00 2001 From: Hessel Date: Thu, 12 Oct 2023 11:59:30 +0200 Subject: [PATCH 380/968] Bump wallbox to 0.4.14 (#101864) --- homeassistant/components/wallbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index fc63e0ca25f..a6e284ff22b 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/wallbox", "iot_class": "cloud_polling", "loggers": ["wallbox"], - "requirements": ["wallbox==0.4.12"] + "requirements": ["wallbox==0.4.14"] } diff --git a/requirements_all.txt b/requirements_all.txt index e5f479d9860..6cb59b2527d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2691,7 +2691,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.4.12 +wallbox==0.4.14 # homeassistant.components.folder_watcher watchdog==2.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 102a72f7612..519767ca943 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2003,7 +2003,7 @@ vultr==0.1.2 wakeonlan==2.1.0 # homeassistant.components.wallbox -wallbox==0.4.12 +wallbox==0.4.14 # homeassistant.components.folder_watcher watchdog==2.3.1 From d676d959014cbc44c3498ddfffbf9a7f0f470847 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 12 Oct 2023 12:51:40 +0200 Subject: [PATCH 381/968] Fix translation key in Plugwise (#101862) Co-authored-by: Robert Resch --- homeassistant/components/plugwise/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index f85c83819fa..82228ee94e7 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -259,7 +259,7 @@ "name": "DHW comfort mode" }, "lock": { - "name": "[%key:component::lock::entity_component::_::name%]" + "name": "[%key:component::lock::title%]" }, "relay": { "name": "Relay" From dcb3dc254da76131532ca213d27551ac5ff3fcc9 Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Thu, 12 Oct 2023 11:52:01 +0100 Subject: [PATCH 382/968] Improve handling of roon media players with fixed and incremental volume (#99819) --- homeassistant/components/roon/media_player.py | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/roon/media_player.py b/homeassistant/components/roon/media_player.py index d56bacd67c4..d6128d26723 100644 --- a/homeassistant/components/roon/media_player.py +++ b/homeassistant/components/roon/media_player.py @@ -92,7 +92,6 @@ class RoonDevice(MediaPlayerEntity): MediaPlayerEntityFeature.BROWSE_MEDIA | MediaPlayerEntityFeature.GROUPING | MediaPlayerEntityFeature.PAUSE - | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.PREVIOUS_TRACK | MediaPlayerEntityFeature.NEXT_TRACK @@ -104,7 +103,6 @@ class RoonDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_MUTE | MediaPlayerEntityFeature.PLAY | MediaPlayerEntityFeature.PLAY_MEDIA - | MediaPlayerEntityFeature.VOLUME_STEP ) def __init__(self, server, player_data): @@ -124,6 +122,8 @@ class RoonDevice(MediaPlayerEntity): self._attr_shuffle = False self._attr_media_image_url = None self._attr_volume_level = 0 + self._volume_fixed = True + self._volume_incremental = False self.update_data(player_data) async def async_added_to_hass(self) -> None: @@ -190,12 +190,21 @@ class RoonDevice(MediaPlayerEntity): "level": 0, "step": 0, "muted": False, + "fixed": True, + "incremental": False, } try: volume_data = player_data["volume"] - volume_muted = volume_data["is_muted"] - volume_step = convert(volume_data["step"], int, 0) + except KeyError: + return volume + + volume["fixed"] = False + volume["incremental"] = volume_data["type"] == "incremental" + volume["muted"] = volume_data.get("is_muted", False) + volume["step"] = convert(volume_data.get("step"), int, 0) + + try: volume_max = volume_data["max"] volume_min = volume_data["min"] raw_level = convert(volume_data["value"], float, 0) @@ -204,15 +213,9 @@ class RoonDevice(MediaPlayerEntity): volume_percentage_factor = volume_range / 100 level = (raw_level - volume_min) / volume_percentage_factor - volume_level = convert(level, int, 0) / 100 - + volume["level"] = convert(level, int, 0) / 100 except KeyError: - # catch KeyError pass - else: - volume["muted"] = volume_muted - volume["step"] = volume_step - volume["level"] = volume_level return volume @@ -288,6 +291,16 @@ class RoonDevice(MediaPlayerEntity): self._attr_is_volume_muted = volume["muted"] self._attr_volume_step = volume["step"] self._attr_volume_level = volume["level"] + self._volume_fixed = volume["fixed"] + self._volume_incremental = volume["incremental"] + if not self._volume_fixed: + self._attr_supported_features = ( + self._attr_supported_features | MediaPlayerEntityFeature.VOLUME_STEP + ) + if not self._volume_incremental: + self._attr_supported_features = ( + self._attr_supported_features | MediaPlayerEntityFeature.VOLUME_SET + ) now_playing = self._parse_now_playing(self.player_data) self._attr_media_title = now_playing["title"] @@ -359,11 +372,17 @@ class RoonDevice(MediaPlayerEntity): def volume_up(self) -> None: """Send new volume_level to device.""" - self._server.roonapi.change_volume_percent(self.output_id, 3) + if self._volume_incremental: + self._server.roonapi.change_volume_raw(self.output_id, 1, "relative_step") + else: + self._server.roonapi.change_volume_percent(self.output_id, 3) def volume_down(self) -> None: """Send new volume_level to device.""" - self._server.roonapi.change_volume_percent(self.output_id, -3) + if self._volume_incremental: + self._server.roonapi.change_volume_raw(self.output_id, -1, "relative_step") + else: + self._server.roonapi.change_volume_percent(self.output_id, -3) def turn_on(self) -> None: """Turn on device (if supported).""" From 8e3c665fd3a14bc2177191fe842dac2354a6a56d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 12 Oct 2023 12:56:10 +0200 Subject: [PATCH 383/968] Only reload Withings config entry on reauth (#101638) * Only reload on reauth * Reload if entry is loaded * Make async_cloudhook_generate_url protected * Fix feedback --- homeassistant/components/withings/__init__.py | 20 ++++--------------- .../components/withings/config_flow.py | 1 + homeassistant/components/withings/const.py | 1 - 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 16606a40645..225ff5603c4 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -43,14 +43,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import ( - CONF_CLOUDHOOK_URL, - CONF_PROFILES, - CONF_USE_WEBHOOK, - DEFAULT_TITLE, - DOMAIN, - LOGGER, -) +from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import WithingsDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -82,6 +75,7 @@ CONFIG_SCHEMA = vol.Schema( ) SUBSCRIBE_DELAY = timedelta(seconds=5) UNSUBSCRIBE_DELAY = timedelta(seconds=1) +CONF_CLOUDHOOK_URL = "cloudhook_url" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -152,7 +146,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _: Any, ) -> None: if cloud.async_active_subscription(hass): - webhook_url = await async_cloudhook_generate_url(hass, entry) + webhook_url = await _async_cloudhook_generate_url(hass, entry) else: webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) @@ -200,7 +194,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(async_call_later(hass, 1, register_webhook)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -214,11 +207,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - async def async_subscribe_webhooks( client: ConfigEntryWithingsApi, webhook_url: str ) -> None: @@ -266,7 +254,7 @@ async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None: ) -async def async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def _async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_id = entry.data[CONF_WEBHOOK_ID] diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 35a4582ae4d..8cab297b96a 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -76,6 +76,7 @@ class WithingsFlowHandler( self.hass.config_entries.async_update_entry( self.reauth_entry, data={**self.reauth_entry.data, **data} ) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 6129e0c4b29..545c7bfcb26 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -5,7 +5,6 @@ import logging DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" -CONF_CLOUDHOOK_URL = "cloudhook_url" DATA_MANAGER = "data_manager" From 52067dbfe512fee3e6e8b410d5be348f18c51cdf Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 12 Oct 2023 13:01:08 +0200 Subject: [PATCH 384/968] Fix mysensors battery level attribute (#101868) --- homeassistant/components/mysensors/device.py | 4 +++- tests/components/mysensors/test_binary_sensor.py | 3 ++- tests/components/mysensors/test_climate.py | 5 ++++- tests/components/mysensors/test_cover.py | 3 ++- tests/components/mysensors/test_device_tracker.py | 8 +++++++- tests/components/mysensors/test_light.py | 4 ++++ tests/components/mysensors/test_remote.py | 8 +++++++- tests/components/mysensors/test_sensor.py | 10 ++++++++++ tests/components/mysensors/test_switch.py | 2 ++ tests/components/mysensors/test_text.py | 3 ++- 10 files changed, 43 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py index 9e1d91c7cce..6d7decf14f4 100644 --- a/homeassistant/components/mysensors/device.py +++ b/homeassistant/components/mysensors/device.py @@ -8,7 +8,7 @@ from typing import Any from mysensors import BaseAsyncGateway, Sensor from mysensors.sensor import ChildSensor -from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo @@ -212,6 +212,8 @@ class MySensorsChildEntity(MySensorNodeEntity): attr[ATTR_CHILD_ID] = self.child_id attr[ATTR_DESCRIPTION] = self._child.description + # We should deprecate the battery level attribute in the future. + attr[ATTR_BATTERY_LEVEL] = self._node.battery_level set_req = self.gateway.const.SetReq for value_type, value in self._values.items(): diff --git a/tests/components/mysensors/test_binary_sensor.py b/tests/components/mysensors/test_binary_sensor.py index 886c13e6ff5..a6dce9c78b9 100644 --- a/tests/components/mysensors/test_binary_sensor.py +++ b/tests/components/mysensors/test_binary_sensor.py @@ -6,7 +6,7 @@ from collections.abc import Callable from mysensors.sensor import Sensor from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS from homeassistant.core import HomeAssistant @@ -23,6 +23,7 @@ async def test_door_sensor( assert state assert state.state == "off" assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 receive_message("1;1;1;0;16;1\n") await hass.async_block_till_done() diff --git a/tests/components/mysensors/test_climate.py b/tests/components/mysensors/test_climate.py index 730960f118d..6c386af6fd6 100644 --- a/tests/components/mysensors/test_climate.py +++ b/tests/components/mysensors/test_climate.py @@ -19,7 +19,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -36,6 +36,7 @@ async def test_hvac_node_auto( assert state assert state.state == HVACMode.OFF + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test set hvac mode auto await hass.services.async_call( @@ -150,6 +151,7 @@ async def test_hvac_node_heat( assert state assert state.state == HVACMode.OFF + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test set hvac mode heat await hass.services.async_call( @@ -259,6 +261,7 @@ async def test_hvac_node_cool( assert state assert state.state == HVACMode.OFF + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test set hvac mode heat await hass.services.async_call( diff --git a/tests/components/mysensors/test_cover.py b/tests/components/mysensors/test_cover.py index 494800f388f..7d0a098fc0a 100644 --- a/tests/components/mysensors/test_cover.py +++ b/tests/components/mysensors/test_cover.py @@ -19,7 +19,7 @@ from homeassistant.components.cover import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -37,6 +37,7 @@ async def test_cover_node_percentage( assert state assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 await hass.services.async_call( COVER_DOMAIN, diff --git a/tests/components/mysensors/test_device_tracker.py b/tests/components/mysensors/test_device_tracker.py index 63c9ed7b1da..4d6e638e665 100644 --- a/tests/components/mysensors/test_device_tracker.py +++ b/tests/components/mysensors/test_device_tracker.py @@ -6,7 +6,12 @@ from collections.abc import Callable from mysensors.sensor import Sensor from homeassistant.components.device_tracker import ATTR_SOURCE_TYPE, SourceType -from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_NOT_HOME +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_LATITUDE, + ATTR_LONGITUDE, + STATE_NOT_HOME, +) from homeassistant.core import HomeAssistant @@ -32,6 +37,7 @@ async def test_gps_sensor( assert state.attributes[ATTR_SOURCE_TYPE] == SourceType.GPS assert state.attributes[ATTR_LATITUDE] == float(latitude) assert state.attributes[ATTR_LONGITUDE] == float(longitude) + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 latitude = "40.782" longitude = "-73.965" diff --git a/tests/components/mysensors/test_light.py b/tests/components/mysensors/test_light.py index 8d4ce445779..9696c6e622a 100644 --- a/tests/components/mysensors/test_light.py +++ b/tests/components/mysensors/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( ATTR_RGBW_COLOR, DOMAIN as LIGHT_DOMAIN, ) +from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant @@ -28,6 +29,7 @@ async def test_dimmer_node( assert state assert state.state == "off" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test turn on await hass.services.async_call( @@ -108,6 +110,7 @@ async def test_rgb_node( assert state assert state.state == "off" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test turn on await hass.services.async_call( @@ -218,6 +221,7 @@ async def test_rgbw_node( assert state assert state.state == "off" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test turn on await hass.services.async_call( diff --git a/tests/components/mysensors/test_remote.py b/tests/components/mysensors/test_remote.py index adc8590914c..586e2e2d048 100644 --- a/tests/components/mysensors/test_remote.py +++ b/tests/components/mysensors/test_remote.py @@ -14,7 +14,12 @@ from homeassistant.components.remote import ( SERVICE_LEARN_COMMAND, SERVICE_SEND_COMMAND, ) -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.core import HomeAssistant @@ -31,6 +36,7 @@ async def test_ir_transceiver( assert state assert state.state == "off" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 # Test turn on await hass.services.async_call( diff --git a/tests/components/mysensors/test_sensor.py b/tests/components/mysensors/test_sensor.py index 17301e4b212..d80fddea9e3 100644 --- a/tests/components/mysensors/test_sensor.py +++ b/tests/components/mysensors/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, @@ -41,6 +42,7 @@ async def test_gps_sensor( assert state assert state.state == "40.741894,-73.989311,12" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 altitude = 0 new_coords = "40.782,-73.965" @@ -67,6 +69,7 @@ async def test_ir_transceiver( assert state assert state.state == "test_code" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 receive_message("1;1;1;0;50;new_code\n") await hass.async_block_till_done() @@ -87,6 +90,7 @@ async def test_battery_entity( state = hass.states.get(battery_entity_id) assert state assert state.state == "42" + assert ATTR_BATTERY_LEVEL not in state.attributes receive_message("1;255;3;0;0;84\n") await hass.async_block_till_done() @@ -94,6 +98,7 @@ async def test_battery_entity( state = hass.states.get(battery_entity_id) assert state assert state.state == "84" + assert ATTR_BATTERY_LEVEL not in state.attributes async def test_power_sensor( @@ -111,6 +116,7 @@ async def test_power_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfPower.WATT assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 async def test_energy_sensor( @@ -128,6 +134,7 @@ async def test_energy_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.TOTAL_INCREASING + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 async def test_sound_sensor( @@ -144,6 +151,7 @@ async def test_sound_sensor( assert state.state == "10" assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.SOUND_PRESSURE assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "dB" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 async def test_distance_sensor( @@ -161,6 +169,7 @@ async def test_distance_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.DISTANCE assert ATTR_ICON not in state.attributes assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "cm" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 @pytest.mark.parametrize( @@ -193,3 +202,4 @@ async def test_temperature_sensor( assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == unit assert state.attributes[ATTR_STATE_CLASS] is SensorStateClass.MEASUREMENT + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 diff --git a/tests/components/mysensors/test_switch.py b/tests/components/mysensors/test_switch.py index 59cea514d77..49786768ff7 100644 --- a/tests/components/mysensors/test_switch.py +++ b/tests/components/mysensors/test_switch.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, call from mysensors.sensor import Sensor from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import HomeAssistant @@ -23,6 +24,7 @@ async def test_relay_node( assert state assert state.state == "off" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 await hass.services.async_call( SWITCH_DOMAIN, diff --git a/tests/components/mysensors/test_text.py b/tests/components/mysensors/test_text.py index 7ed46532c8a..7490cfddfbf 100644 --- a/tests/components/mysensors/test_text.py +++ b/tests/components/mysensors/test_text.py @@ -12,7 +12,7 @@ from homeassistant.components.text import ( DOMAIN as TEXT_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant @@ -29,6 +29,7 @@ async def test_text_node( assert state assert state.state == "test" + assert state.attributes[ATTR_BATTERY_LEVEL] == 0 await hass.services.async_call( TEXT_DOMAIN, From b70e2f77495580132d821106ab1fb5842369e86c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Thu, 12 Oct 2023 13:03:09 +0200 Subject: [PATCH 385/968] Fix SMA incorrect device class (#101866) --- homeassistant/components/sma/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index f0fc475e0db..abf5c9a878f 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -274,8 +274,6 @@ SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { "grid_power_factor_excitation": SensorEntityDescription( key="grid_power_factor_excitation", name="Grid Power Factor Excitation", - state_class=SensorStateClass.MEASUREMENT, - device_class=SensorDeviceClass.POWER_FACTOR, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), From 91cf719588e4c47ea2a19ddc5ad6f514794183de Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler <2292715+bdr99@users.noreply.github.com> Date: Thu, 12 Oct 2023 07:13:44 -0400 Subject: [PATCH 386/968] Remove Mazda integration (#101849) Co-authored-by: Franck Nijhof --- CODEOWNERS | 2 - homeassistant/components/mazda/__init__.py | 263 +---------- .../components/mazda/binary_sensor.py | 131 ------ homeassistant/components/mazda/button.py | 150 ------- homeassistant/components/mazda/climate.py | 187 -------- homeassistant/components/mazda/config_flow.py | 107 +---- homeassistant/components/mazda/const.py | 10 - .../components/mazda/device_tracker.py | 54 --- homeassistant/components/mazda/diagnostics.py | 57 --- homeassistant/components/mazda/lock.py | 58 --- homeassistant/components/mazda/manifest.json | 8 +- homeassistant/components/mazda/sensor.py | 263 ----------- homeassistant/components/mazda/services.yaml | 30 -- homeassistant/components/mazda/strings.json | 139 +----- homeassistant/components/mazda/switch.py | 72 --- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/mazda/__init__.py | 79 ---- .../fixtures/diagnostics_config_entry.json | 62 --- .../mazda/fixtures/diagnostics_device.json | 60 --- .../mazda/fixtures/get_ev_vehicle_status.json | 19 - .../mazda/fixtures/get_hvac_setting.json | 6 - .../mazda/fixtures/get_vehicle_status.json | 37 -- .../mazda/fixtures/get_vehicles.json | 18 - tests/components/mazda/test_binary_sensor.py | 98 ---- tests/components/mazda/test_button.py | 145 ------ tests/components/mazda/test_climate.py | 341 -------------- tests/components/mazda/test_config_flow.py | 423 ------------------ tests/components/mazda/test_device_tracker.py | 30 -- tests/components/mazda/test_diagnostics.py | 81 ---- tests/components/mazda/test_init.py | 383 ++-------------- tests/components/mazda/test_lock.py | 58 --- tests/components/mazda/test_sensor.py | 195 -------- tests/components/mazda/test_switch.py | 69 --- 36 files changed, 66 insertions(+), 3582 deletions(-) delete mode 100644 homeassistant/components/mazda/binary_sensor.py delete mode 100644 homeassistant/components/mazda/button.py delete mode 100644 homeassistant/components/mazda/climate.py delete mode 100644 homeassistant/components/mazda/const.py delete mode 100644 homeassistant/components/mazda/device_tracker.py delete mode 100644 homeassistant/components/mazda/diagnostics.py delete mode 100644 homeassistant/components/mazda/lock.py delete mode 100644 homeassistant/components/mazda/sensor.py delete mode 100644 homeassistant/components/mazda/services.yaml delete mode 100644 homeassistant/components/mazda/switch.py delete mode 100644 tests/components/mazda/fixtures/diagnostics_config_entry.json delete mode 100644 tests/components/mazda/fixtures/diagnostics_device.json delete mode 100644 tests/components/mazda/fixtures/get_ev_vehicle_status.json delete mode 100644 tests/components/mazda/fixtures/get_hvac_setting.json delete mode 100644 tests/components/mazda/fixtures/get_vehicle_status.json delete mode 100644 tests/components/mazda/fixtures/get_vehicles.json delete mode 100644 tests/components/mazda/test_binary_sensor.py delete mode 100644 tests/components/mazda/test_button.py delete mode 100644 tests/components/mazda/test_climate.py delete mode 100644 tests/components/mazda/test_config_flow.py delete mode 100644 tests/components/mazda/test_device_tracker.py delete mode 100644 tests/components/mazda/test_diagnostics.py delete mode 100644 tests/components/mazda/test_lock.py delete mode 100644 tests/components/mazda/test_sensor.py delete mode 100644 tests/components/mazda/test_switch.py diff --git a/CODEOWNERS b/CODEOWNERS index 8dacaa7021e..880dd552cbc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -738,8 +738,6 @@ build.json @home-assistant/supervisor /tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter -/homeassistant/components/mazda/ @bdr99 -/tests/components/mazda/ @bdr99 /homeassistant/components/meater/ @Sotolotl @emontnemery /tests/components/meater/ @Sotolotl @emontnemery /homeassistant/components/medcom_ble/ @elafargue diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index f375b8a75cd..75e7baf7413 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -1,213 +1,26 @@ """The Mazda Connected Services integration.""" from __future__ import annotations -import asyncio -from datetime import timedelta -import logging -from typing import TYPE_CHECKING +from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -from pymazda import ( - Client as MazdaAPI, - MazdaAccountLockedException, - MazdaAPIEncryptionException, - MazdaAuthenticationException, - MazdaException, - MazdaTokenExpiredException, -) -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers import ( - aiohttp_client, - config_validation as cv, - device_registry as dr, -) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) - -from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DATA_VEHICLES, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.CLIMATE, - Platform.DEVICE_TRACKER, - Platform.LOCK, - Platform.SENSOR, - Platform.SWITCH, -] +DOMAIN = "mazda" -async def with_timeout(task, timeout_seconds=30): - """Run an async task with a timeout.""" - async with asyncio.timeout(timeout_seconds): - return await task - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: """Set up Mazda Connected Services from a config entry.""" - email = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - region = entry.data[CONF_REGION] - - websession = aiohttp_client.async_get_clientsession(hass) - mazda_client = MazdaAPI( - email, password, region, websession=websession, use_cached_vehicle_list=True - ) - - try: - await mazda_client.validate_credentials() - except MazdaAuthenticationException as ex: - raise ConfigEntryAuthFailed from ex - except ( - MazdaException, - MazdaAccountLockedException, - MazdaTokenExpiredException, - MazdaAPIEncryptionException, - ) as ex: - _LOGGER.error("Error occurred during Mazda login request: %s", ex) - raise ConfigEntryNotReady from ex - - async def async_handle_service_call(service_call: ServiceCall) -> None: - """Handle a service call.""" - # Get device entry from device registry - dev_reg = dr.async_get(hass) - device_id = service_call.data["device_id"] - device_entry = dev_reg.async_get(device_id) - if TYPE_CHECKING: - # For mypy: it has already been checked in validate_mazda_device_id - assert device_entry - - # Get vehicle VIN from device identifiers - mazda_identifiers = ( - identifier - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ) - vin_identifier = next(mazda_identifiers) - vin = vin_identifier[1] - - # Get vehicle ID and API client from hass.data - vehicle_id = 0 - api_client = None - for entry_data in hass.data[DOMAIN].values(): - for vehicle in entry_data[DATA_VEHICLES]: - if vehicle["vin"] == vin: - vehicle_id = vehicle["id"] - api_client = entry_data[DATA_CLIENT] - break - - if vehicle_id == 0 or api_client is None: - raise HomeAssistantError("Vehicle ID not found") - - api_method = getattr(api_client, service_call.service) - try: - latitude = service_call.data["latitude"] - longitude = service_call.data["longitude"] - poi_name = service_call.data["poi_name"] - await api_method(vehicle_id, latitude, longitude, poi_name) - except Exception as ex: - raise HomeAssistantError(ex) from ex - - def validate_mazda_device_id(device_id): - """Check that a device ID exists in the registry and has at least one 'mazda' identifier.""" - dev_reg = dr.async_get(hass) - - if (device_entry := dev_reg.async_get(device_id)) is None: - raise vol.Invalid("Invalid device ID") - - mazda_identifiers = [ - identifier - for identifier in device_entry.identifiers - if identifier[0] == DOMAIN - ] - if not mazda_identifiers: - raise vol.Invalid("Device ID is not a Mazda vehicle") - - return device_id - - service_schema_send_poi = vol.Schema( - { - vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id), - vol.Required("latitude"): cv.latitude, - vol.Required("longitude"): cv.longitude, - vol.Required("poi_name"): cv.string, - } - ) - - async def async_update_data(): - """Fetch data from Mazda API.""" - try: - vehicles = await with_timeout(mazda_client.get_vehicles()) - - # The Mazda API can throw an error when multiple simultaneous requests are - # made for the same account, so we can only make one request at a time here - for vehicle in vehicles: - vehicle["status"] = await with_timeout( - mazda_client.get_vehicle_status(vehicle["id"]) - ) - - # If vehicle is electric, get additional EV-specific status info - if vehicle["isElectric"]: - vehicle["evStatus"] = await with_timeout( - mazda_client.get_ev_vehicle_status(vehicle["id"]) - ) - vehicle["hvacSetting"] = await with_timeout( - mazda_client.get_hvac_setting(vehicle["id"]) - ) - - hass.data[DOMAIN][entry.entry_id][DATA_VEHICLES] = vehicles - - return vehicles - except MazdaAuthenticationException as ex: - raise ConfigEntryAuthFailed("Not authenticated with Mazda API") from ex - except Exception as ex: - _LOGGER.exception( - "Unknown error occurred during Mazda update request: %s", ex - ) - raise UpdateFailed(ex) from ex - - coordinator = DataUpdateCoordinator( + ir.async_create_issue( hass, - _LOGGER, - name=DOMAIN, - update_method=async_update_data, - update_interval=timedelta(seconds=180), - ) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_CLIENT: mazda_client, - DATA_COORDINATOR: coordinator, - DATA_REGION: region, - DATA_VEHICLES: [], - } - - # Fetch initial data so we have data when entities subscribe - await coordinator.async_config_entry_first_refresh() - - # Setup components - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - - # Register services - hass.services.async_register( DOMAIN, - "send_poi", - async_handle_service_call, - schema=service_schema_send_poi, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "dmca": "https://github.com/github/dmca/blob/master/2023/10/2023-10-10-mazda.md", + "entries": "/config/integrations/integration/mazda", + }, ) return True @@ -215,45 +28,11 @@ 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 all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - # Only remove services if it is the last config entry - if len(hass.data[DOMAIN]) == 1: - hass.services.async_remove(DOMAIN, "send_poi") - - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -class MazdaEntity(CoordinatorEntity): - """Defines a base Mazda entity.""" - - _attr_has_entity_name = True - - def __init__(self, client, coordinator, index): - """Initialize the Mazda entity.""" - super().__init__(coordinator) - self.client = client - self.index = index - self.vin = self.data["vin"] - self.vehicle_id = self.data["id"] - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.vin)}, - manufacturer="Mazda", - model=f"{self.data['modelYear']} {self.data['carlineName']}", - name=self.vehicle_name, - ) - - @property - def data(self): - """Shortcut to access coordinator data for the entity.""" - return self.coordinator.data[self.index] - - @property - def vehicle_name(self): - """Return the vehicle name, to be used as a prefix for names of other entities.""" - if "nickname" in self.data and len(self.data["nickname"]) > 0: - return self.data["nickname"] - return f"{self.data['modelYear']} {self.data['carlineName']}" + return True diff --git a/homeassistant/components/mazda/binary_sensor.py b/homeassistant/components/mazda/binary_sensor.py deleted file mode 100644 index 36c3ba27463..00000000000 --- a/homeassistant/components/mazda/binary_sensor.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Platform for Mazda binary sensor integration.""" -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, - BinarySensorEntityDescription, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -@dataclass -class MazdaBinarySensorRequiredKeysMixin: - """Mixin for required keys.""" - - # Function to determine the value for this binary sensor, given the coordinator data - value_fn: Callable[[dict[str, Any]], bool] - - -@dataclass -class MazdaBinarySensorEntityDescription( - BinarySensorEntityDescription, MazdaBinarySensorRequiredKeysMixin -): - """Describes a Mazda binary sensor entity.""" - - # Function to determine whether the vehicle supports this binary sensor, given the coordinator data - is_supported: Callable[[dict[str, Any]], bool] = lambda data: True - - -def _plugged_in_supported(data): - """Determine if 'plugged in' binary sensor is supported.""" - return ( - data["isElectric"] and data["evStatus"]["chargeInfo"]["pluggedIn"] is not None - ) - - -BINARY_SENSOR_ENTITIES = [ - MazdaBinarySensorEntityDescription( - key="driver_door", - translation_key="driver_door", - icon="mdi:car-door", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["driverDoorOpen"], - ), - MazdaBinarySensorEntityDescription( - key="passenger_door", - translation_key="passenger_door", - icon="mdi:car-door", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["passengerDoorOpen"], - ), - MazdaBinarySensorEntityDescription( - key="rear_left_door", - translation_key="rear_left_door", - icon="mdi:car-door", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["rearLeftDoorOpen"], - ), - MazdaBinarySensorEntityDescription( - key="rear_right_door", - translation_key="rear_right_door", - icon="mdi:car-door", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["rearRightDoorOpen"], - ), - MazdaBinarySensorEntityDescription( - key="trunk", - translation_key="trunk", - icon="mdi:car-back", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["trunkOpen"], - ), - MazdaBinarySensorEntityDescription( - key="hood", - translation_key="hood", - icon="mdi:car", - device_class=BinarySensorDeviceClass.DOOR, - value_fn=lambda data: data["status"]["doors"]["hoodOpen"], - ), - MazdaBinarySensorEntityDescription( - key="ev_plugged_in", - translation_key="ev_plugged_in", - device_class=BinarySensorDeviceClass.PLUG, - is_supported=_plugged_in_supported, - value_fn=lambda data: data["evStatus"]["chargeInfo"]["pluggedIn"], - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the sensor platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - async_add_entities( - MazdaBinarySensorEntity(client, coordinator, index, description) - for index, data in enumerate(coordinator.data) - for description in BINARY_SENSOR_ENTITIES - if description.is_supported(data) - ) - - -class MazdaBinarySensorEntity(MazdaEntity, BinarySensorEntity): - """Representation of a Mazda vehicle binary sensor.""" - - entity_description: MazdaBinarySensorEntityDescription - - def __init__(self, client, coordinator, index, description): - """Initialize Mazda binary sensor.""" - super().__init__(client, coordinator, index) - self.entity_description = description - - self._attr_unique_id = f"{self.vin}_{description.key}" - - @property - def is_on(self): - """Return the state of the binary sensor.""" - return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/mazda/button.py b/homeassistant/components/mazda/button.py deleted file mode 100644 index ced1094981f..00000000000 --- a/homeassistant/components/mazda/button.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Platform for Mazda button integration.""" -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import Any - -from pymazda import ( - Client as MazdaAPIClient, - MazdaAccountLockedException, - MazdaAPIEncryptionException, - MazdaAuthenticationException, - MazdaException, - MazdaLoginFailedException, - MazdaTokenExpiredException, -) - -from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -async def handle_button_press( - client: MazdaAPIClient, - key: str, - vehicle_id: int, - coordinator: DataUpdateCoordinator, -) -> None: - """Handle a press for a Mazda button entity.""" - api_method = getattr(client, key) - - try: - await api_method(vehicle_id) - except ( - MazdaException, - MazdaAuthenticationException, - MazdaAccountLockedException, - MazdaTokenExpiredException, - MazdaAPIEncryptionException, - MazdaLoginFailedException, - ) as ex: - raise HomeAssistantError(ex) from ex - - -async def handle_refresh_vehicle_status( - client: MazdaAPIClient, - key: str, - vehicle_id: int, - coordinator: DataUpdateCoordinator, -) -> None: - """Handle a request to refresh the vehicle status.""" - await handle_button_press(client, key, vehicle_id, coordinator) - - await coordinator.async_request_refresh() - - -@dataclass -class MazdaButtonEntityDescription(ButtonEntityDescription): - """Describes a Mazda button entity.""" - - # Function to determine whether the vehicle supports this button, - # given the coordinator data - is_supported: Callable[[dict[str, Any]], bool] = lambda data: True - - async_press: Callable[ - [MazdaAPIClient, str, int, DataUpdateCoordinator], Awaitable - ] = handle_button_press - - -BUTTON_ENTITIES = [ - MazdaButtonEntityDescription( - key="start_engine", - translation_key="start_engine", - icon="mdi:engine", - is_supported=lambda data: not data["isElectric"], - ), - MazdaButtonEntityDescription( - key="stop_engine", - translation_key="stop_engine", - icon="mdi:engine-off", - is_supported=lambda data: not data["isElectric"], - ), - MazdaButtonEntityDescription( - key="turn_on_hazard_lights", - translation_key="turn_on_hazard_lights", - icon="mdi:hazard-lights", - is_supported=lambda data: not data["isElectric"], - ), - MazdaButtonEntityDescription( - key="turn_off_hazard_lights", - translation_key="turn_off_hazard_lights", - icon="mdi:hazard-lights", - is_supported=lambda data: not data["isElectric"], - ), - MazdaButtonEntityDescription( - key="refresh_vehicle_status", - translation_key="refresh_vehicle_status", - icon="mdi:refresh", - async_press=handle_refresh_vehicle_status, - is_supported=lambda data: data["isElectric"], - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the button platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - async_add_entities( - MazdaButtonEntity(client, coordinator, index, description) - for index, data in enumerate(coordinator.data) - for description in BUTTON_ENTITIES - if description.is_supported(data) - ) - - -class MazdaButtonEntity(MazdaEntity, ButtonEntity): - """Representation of a Mazda button.""" - - entity_description: MazdaButtonEntityDescription - - def __init__( - self, - client: MazdaAPIClient, - coordinator: DataUpdateCoordinator, - index: int, - description: MazdaButtonEntityDescription, - ) -> None: - """Initialize Mazda button.""" - super().__init__(client, coordinator, index) - self.entity_description = description - - self._attr_unique_id = f"{self.vin}_{description.key}" - - async def async_press(self) -> None: - """Press the button.""" - await self.entity_description.async_press( - self.client, self.entity_description.key, self.vehicle_id, self.coordinator - ) diff --git a/homeassistant/components/mazda/climate.py b/homeassistant/components/mazda/climate.py deleted file mode 100644 index 43dc4b4151d..00000000000 --- a/homeassistant/components/mazda/climate.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Platform for Mazda climate integration.""" -from __future__ import annotations - -from typing import Any - -from pymazda import Client as MazdaAPIClient - -from homeassistant.components.climate import ( - ClimateEntity, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_HALVES, - PRECISION_WHOLE, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util.unit_conversion import TemperatureConverter - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_REGION, DOMAIN - -PRESET_DEFROSTER_OFF = "Defroster Off" -PRESET_DEFROSTER_FRONT = "Front Defroster" -PRESET_DEFROSTER_REAR = "Rear Defroster" -PRESET_DEFROSTER_FRONT_AND_REAR = "Front and Rear Defroster" - - -def _front_defroster_enabled(preset_mode: str | None) -> bool: - return preset_mode in [ - PRESET_DEFROSTER_FRONT_AND_REAR, - PRESET_DEFROSTER_FRONT, - ] - - -def _rear_defroster_enabled(preset_mode: str | None) -> bool: - return preset_mode in [ - PRESET_DEFROSTER_FRONT_AND_REAR, - PRESET_DEFROSTER_REAR, - ] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the climate platform.""" - entry_data = hass.data[DOMAIN][config_entry.entry_id] - client = entry_data[DATA_CLIENT] - coordinator = entry_data[DATA_COORDINATOR] - region = entry_data[DATA_REGION] - - async_add_entities( - MazdaClimateEntity(client, coordinator, index, region) - for index, data in enumerate(coordinator.data) - if data["isElectric"] - ) - - -class MazdaClimateEntity(MazdaEntity, ClimateEntity): - """Class for a Mazda climate entity.""" - - _attr_translation_key = "climate" - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - _attr_hvac_modes = [HVACMode.HEAT_COOL, HVACMode.OFF] - _attr_preset_modes = [ - PRESET_DEFROSTER_OFF, - PRESET_DEFROSTER_FRONT, - PRESET_DEFROSTER_REAR, - PRESET_DEFROSTER_FRONT_AND_REAR, - ] - - def __init__( - self, - client: MazdaAPIClient, - coordinator: DataUpdateCoordinator, - index: int, - region: str, - ) -> None: - """Initialize Mazda climate entity.""" - super().__init__(client, coordinator, index) - - self.region = region - self._attr_unique_id = self.vin - - if self.data["hvacSetting"]["temperatureUnit"] == "F": - self._attr_precision = PRECISION_WHOLE - self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT - self._attr_min_temp = 61.0 - self._attr_max_temp = 83.0 - else: - self._attr_precision = PRECISION_HALVES - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - if region == "MJO": - self._attr_min_temp = 18.5 - self._attr_max_temp = 31.5 - else: - self._attr_min_temp = 15.5 - self._attr_max_temp = 28.5 - - self._update_state_attributes() - - @callback - def _handle_coordinator_update(self) -> None: - """Update attributes when the coordinator data updates.""" - self._update_state_attributes() - - super()._handle_coordinator_update() - - def _update_state_attributes(self) -> None: - # Update the HVAC mode - hvac_on = self.client.get_assumed_hvac_mode(self.vehicle_id) - self._attr_hvac_mode = HVACMode.HEAT_COOL if hvac_on else HVACMode.OFF - - # Update the target temperature - hvac_setting = self.client.get_assumed_hvac_setting(self.vehicle_id) - self._attr_target_temperature = hvac_setting.get("temperature") - - # Update the current temperature - current_temperature_celsius = self.data["evStatus"]["hvacInfo"][ - "interiorTemperatureCelsius" - ] - if self.data["hvacSetting"]["temperatureUnit"] == "F": - self._attr_current_temperature = TemperatureConverter.convert( - current_temperature_celsius, - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ) - else: - self._attr_current_temperature = current_temperature_celsius - - # Update the preset mode based on the state of the front and rear defrosters - front_defroster = hvac_setting.get("frontDefroster") - rear_defroster = hvac_setting.get("rearDefroster") - if front_defroster and rear_defroster: - self._attr_preset_mode = PRESET_DEFROSTER_FRONT_AND_REAR - elif front_defroster: - self._attr_preset_mode = PRESET_DEFROSTER_FRONT - elif rear_defroster: - self._attr_preset_mode = PRESET_DEFROSTER_REAR - else: - self._attr_preset_mode = PRESET_DEFROSTER_OFF - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set a new HVAC mode.""" - if hvac_mode == HVACMode.HEAT_COOL: - await self.client.turn_on_hvac(self.vehicle_id) - elif hvac_mode == HVACMode.OFF: - await self.client.turn_off_hvac(self.vehicle_id) - - self._handle_coordinator_update() - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set a new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is not None: - precision = self.precision - rounded_temperature = round(temperature / precision) * precision - - await self.client.set_hvac_setting( - self.vehicle_id, - rounded_temperature, - self.data["hvacSetting"]["temperatureUnit"], - _front_defroster_enabled(self._attr_preset_mode), - _rear_defroster_enabled(self._attr_preset_mode), - ) - - self._handle_coordinator_update() - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Turn on/off the front/rear defrosters according to the chosen preset mode.""" - await self.client.set_hvac_setting( - self.vehicle_id, - self._attr_target_temperature, - self.data["hvacSetting"]["temperatureUnit"], - _front_defroster_enabled(preset_mode), - _rear_defroster_enabled(preset_mode), - ) - - self._handle_coordinator_update() diff --git a/homeassistant/components/mazda/config_flow.py b/homeassistant/components/mazda/config_flow.py index 0b255483da1..78a939df69d 100644 --- a/homeassistant/components/mazda/config_flow.py +++ b/homeassistant/components/mazda/config_flow.py @@ -1,110 +1,11 @@ -"""Config flow for Mazda Connected Services integration.""" -from collections.abc import Mapping -import logging -from typing import Any +"""The Mazda Connected Services integration.""" -import aiohttp -from pymazda import ( - Client as MazdaAPI, - MazdaAccountLockedException, - MazdaAuthenticationException, -) -import voluptuous as vol +from homeassistant.config_entries import ConfigFlow -from homeassistant import config_entries -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client - -from .const import DOMAIN, MAZDA_REGIONS - -_LOGGER = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_EMAIL): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION): vol.In(MAZDA_REGIONS), - } -) +from . import DOMAIN -class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class MazdaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Mazda Connected Services.""" VERSION = 1 - - def __init__(self): - """Start the mazda config flow.""" - self._reauth_entry = None - self._email = None - self._region = None - - async def async_step_user(self, user_input=None): - """Handle the initial step.""" - errors = {} - - if user_input is not None: - self._email = user_input[CONF_EMAIL] - self._region = user_input[CONF_REGION] - unique_id = user_input[CONF_EMAIL].lower() - await self.async_set_unique_id(unique_id) - if not self._reauth_entry: - self._abort_if_unique_id_configured() - websession = aiohttp_client.async_get_clientsession(self.hass) - mazda_client = MazdaAPI( - user_input[CONF_EMAIL], - user_input[CONF_PASSWORD], - user_input[CONF_REGION], - websession, - ) - - try: - await mazda_client.validate_credentials() - except MazdaAuthenticationException: - errors["base"] = "invalid_auth" - except MazdaAccountLockedException: - errors["base"] = "account_locked" - except aiohttp.ClientError: - errors["base"] = "cannot_connect" - except Exception as ex: # pylint: disable=broad-except - errors["base"] = "unknown" - _LOGGER.exception( - "Unknown error occurred during Mazda login request: %s", ex - ) - else: - if not self._reauth_entry: - return self.async_create_entry( - title=user_input[CONF_EMAIL], data=user_input - ) - self.hass.config_entries.async_update_entry( - self._reauth_entry, data=user_input, unique_id=unique_id - ) - # Reload the config entry otherwise devices will remain unavailable - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._reauth_entry.entry_id) - ) - return self.async_abort(reason="reauth_successful") - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema( - { - vol.Required(CONF_EMAIL, default=self._email): str, - vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_REGION, default=self._region): vol.In( - MAZDA_REGIONS - ), - } - ), - errors=errors, - ) - - async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: - """Perform reauth if the user credentials have changed.""" - self._reauth_entry = self.hass.config_entries.async_get_entry( - self.context["entry_id"] - ) - self._email = entry_data[CONF_EMAIL] - self._region = entry_data[CONF_REGION] - return await self.async_step_user() diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py deleted file mode 100644 index ebfa7f05301..00000000000 --- a/homeassistant/components/mazda/const.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Constants for the Mazda Connected Services integration.""" - -DOMAIN = "mazda" - -DATA_CLIENT = "mazda_client" -DATA_COORDINATOR = "coordinator" -DATA_REGION = "region" -DATA_VEHICLES = "vehicles" - -MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} diff --git a/homeassistant/components/mazda/device_tracker.py b/homeassistant/components/mazda/device_tracker.py deleted file mode 100644 index 2af191f97bc..00000000000 --- a/homeassistant/components/mazda/device_tracker.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Platform for Mazda device tracker integration.""" -from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the device tracker platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - entities = [] - - for index, _ in enumerate(coordinator.data): - entities.append(MazdaDeviceTracker(client, coordinator, index)) - - async_add_entities(entities) - - -class MazdaDeviceTracker(MazdaEntity, TrackerEntity): - """Class for the device tracker.""" - - _attr_translation_key = "device_tracker" - _attr_icon = "mdi:car" - _attr_force_update = False - - def __init__(self, client, coordinator, index) -> None: - """Initialize Mazda device tracker.""" - super().__init__(client, coordinator, index) - - self._attr_unique_id = self.vin - - @property - def source_type(self) -> SourceType: - """Return the source type, eg gps or router, of the device.""" - return SourceType.GPS - - @property - def latitude(self): - """Return latitude value of the device.""" - return self.data["status"]["latitude"] - - @property - def longitude(self): - """Return longitude value of the device.""" - return self.data["status"]["longitude"] diff --git a/homeassistant/components/mazda/diagnostics.py b/homeassistant/components/mazda/diagnostics.py deleted file mode 100644 index 421410f4a34..00000000000 --- a/homeassistant/components/mazda/diagnostics.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Diagnostics support for the Mazda integration.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceEntry - -from .const import DATA_COORDINATOR, DOMAIN - -TO_REDACT_INFO = [CONF_EMAIL, CONF_PASSWORD] -TO_REDACT_DATA = ["vin", "id", "latitude", "longitude"] - - -async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry -) -> dict[str, Any]: - """Return diagnostics for a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - diagnostics_data = { - "info": async_redact_data(config_entry.data, TO_REDACT_INFO), - "data": [ - async_redact_data(vehicle, TO_REDACT_DATA) for vehicle in coordinator.data - ], - } - - return diagnostics_data - - -async def async_get_device_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry, device: DeviceEntry -) -> dict[str, Any]: - """Return diagnostics for a device.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - vin = next(iter(device.identifiers))[1] - - target_vehicle = None - for vehicle in coordinator.data: - if vehicle["vin"] == vin: - target_vehicle = vehicle - break - - if target_vehicle is None: - raise HomeAssistantError("Vehicle not found") - - diagnostics_data = { - "info": async_redact_data(config_entry.data, TO_REDACT_INFO), - "data": async_redact_data(target_vehicle, TO_REDACT_DATA), - } - - return diagnostics_data diff --git a/homeassistant/components/mazda/lock.py b/homeassistant/components/mazda/lock.py deleted file mode 100644 index d095ac81955..00000000000 --- a/homeassistant/components/mazda/lock.py +++ /dev/null @@ -1,58 +0,0 @@ -"""Platform for Mazda lock integration.""" -from __future__ import annotations - -from typing import Any - -from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the lock platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - entities = [] - - for index, _ in enumerate(coordinator.data): - entities.append(MazdaLock(client, coordinator, index)) - - async_add_entities(entities) - - -class MazdaLock(MazdaEntity, LockEntity): - """Class for the lock.""" - - _attr_translation_key = "lock" - - def __init__(self, client, coordinator, index) -> None: - """Initialize Mazda lock.""" - super().__init__(client, coordinator, index) - - self._attr_unique_id = self.vin - - @property - def is_locked(self) -> bool | None: - """Return true if lock is locked.""" - return self.client.get_assumed_lock_state(self.vehicle_id) - - async def async_lock(self, **kwargs: Any) -> None: - """Lock the vehicle doors.""" - await self.client.lock_doors(self.vehicle_id) - - self.async_write_ha_state() - - async def async_unlock(self, **kwargs: Any) -> None: - """Unlock the vehicle doors.""" - await self.client.unlock_doors(self.vehicle_id) - - self.async_write_ha_state() diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index 881120a0677..75a83a9f468 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -1,11 +1,9 @@ { "domain": "mazda", "name": "Mazda Connected Services", - "codeowners": ["@bdr99"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/mazda", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pymazda"], - "quality_scale": "platinum", - "requirements": ["pymazda==0.3.11"] + "requirements": [] } diff --git a/homeassistant/components/mazda/sensor.py b/homeassistant/components/mazda/sensor.py deleted file mode 100644 index f50533e339a..00000000000 --- a/homeassistant/components/mazda/sensor.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Platform for Mazda sensor integration.""" -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfLength, UnitOfPressure -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -@dataclass -class MazdaSensorRequiredKeysMixin: - """Mixin for required keys.""" - - # Function to determine the value for this sensor, given the coordinator data - # and the configured unit system - value: Callable[[dict[str, Any]], StateType] - - -@dataclass -class MazdaSensorEntityDescription( - SensorEntityDescription, MazdaSensorRequiredKeysMixin -): - """Describes a Mazda sensor entity.""" - - # Function to determine whether the vehicle supports this sensor, - # given the coordinator data - is_supported: Callable[[dict[str, Any]], bool] = lambda data: True - - -def _fuel_remaining_percentage_supported(data): - """Determine if fuel remaining percentage is supported.""" - return (not data["isElectric"]) and ( - data["status"]["fuelRemainingPercent"] is not None - ) - - -def _fuel_distance_remaining_supported(data): - """Determine if fuel distance remaining is supported.""" - return (not data["isElectric"]) and ( - data["status"]["fuelDistanceRemainingKm"] is not None - ) - - -def _front_left_tire_pressure_supported(data): - """Determine if front left tire pressure is supported.""" - return data["status"]["tirePressure"]["frontLeftTirePressurePsi"] is not None - - -def _front_right_tire_pressure_supported(data): - """Determine if front right tire pressure is supported.""" - return data["status"]["tirePressure"]["frontRightTirePressurePsi"] is not None - - -def _rear_left_tire_pressure_supported(data): - """Determine if rear left tire pressure is supported.""" - return data["status"]["tirePressure"]["rearLeftTirePressurePsi"] is not None - - -def _rear_right_tire_pressure_supported(data): - """Determine if rear right tire pressure is supported.""" - return data["status"]["tirePressure"]["rearRightTirePressurePsi"] is not None - - -def _ev_charge_level_supported(data): - """Determine if charge level is supported.""" - return ( - data["isElectric"] - and data["evStatus"]["chargeInfo"]["batteryLevelPercentage"] is not None - ) - - -def _ev_remaining_range_supported(data): - """Determine if remaining range is supported.""" - return ( - data["isElectric"] - and data["evStatus"]["chargeInfo"]["drivingRangeKm"] is not None - ) - - -def _fuel_distance_remaining_value(data): - """Get the fuel distance remaining value.""" - return round(data["status"]["fuelDistanceRemainingKm"]) - - -def _odometer_value(data): - """Get the odometer value.""" - # In order to match the behavior of the Mazda mobile app, we always round down - return int(data["status"]["odometerKm"]) - - -def _front_left_tire_pressure_value(data): - """Get the front left tire pressure value.""" - return round(data["status"]["tirePressure"]["frontLeftTirePressurePsi"]) - - -def _front_right_tire_pressure_value(data): - """Get the front right tire pressure value.""" - return round(data["status"]["tirePressure"]["frontRightTirePressurePsi"]) - - -def _rear_left_tire_pressure_value(data): - """Get the rear left tire pressure value.""" - return round(data["status"]["tirePressure"]["rearLeftTirePressurePsi"]) - - -def _rear_right_tire_pressure_value(data): - """Get the rear right tire pressure value.""" - return round(data["status"]["tirePressure"]["rearRightTirePressurePsi"]) - - -def _ev_charge_level_value(data): - """Get the charge level value.""" - return round(data["evStatus"]["chargeInfo"]["batteryLevelPercentage"]) - - -def _ev_remaining_range_value(data): - """Get the remaining range value.""" - return round(data["evStatus"]["chargeInfo"]["drivingRangeKm"]) - - -SENSOR_ENTITIES = [ - MazdaSensorEntityDescription( - key="fuel_remaining_percentage", - translation_key="fuel_remaining_percentage", - icon="mdi:gas-station", - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_fuel_remaining_percentage_supported, - value=lambda data: data["status"]["fuelRemainingPercent"], - ), - MazdaSensorEntityDescription( - key="fuel_distance_remaining", - translation_key="fuel_distance_remaining", - icon="mdi:gas-station", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_fuel_distance_remaining_supported, - value=_fuel_distance_remaining_value, - ), - MazdaSensorEntityDescription( - key="odometer", - translation_key="odometer", - icon="mdi:speedometer", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.TOTAL_INCREASING, - is_supported=lambda data: data["status"]["odometerKm"] is not None, - value=_odometer_value, - ), - MazdaSensorEntityDescription( - key="front_left_tire_pressure", - translation_key="front_left_tire_pressure", - icon="mdi:car-tire-alert", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.PSI, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_front_left_tire_pressure_supported, - value=_front_left_tire_pressure_value, - ), - MazdaSensorEntityDescription( - key="front_right_tire_pressure", - translation_key="front_right_tire_pressure", - icon="mdi:car-tire-alert", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.PSI, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_front_right_tire_pressure_supported, - value=_front_right_tire_pressure_value, - ), - MazdaSensorEntityDescription( - key="rear_left_tire_pressure", - translation_key="rear_left_tire_pressure", - icon="mdi:car-tire-alert", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.PSI, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_rear_left_tire_pressure_supported, - value=_rear_left_tire_pressure_value, - ), - MazdaSensorEntityDescription( - key="rear_right_tire_pressure", - translation_key="rear_right_tire_pressure", - icon="mdi:car-tire-alert", - device_class=SensorDeviceClass.PRESSURE, - native_unit_of_measurement=UnitOfPressure.PSI, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_rear_right_tire_pressure_supported, - value=_rear_right_tire_pressure_value, - ), - MazdaSensorEntityDescription( - key="ev_charge_level", - translation_key="ev_charge_level", - device_class=SensorDeviceClass.BATTERY, - native_unit_of_measurement=PERCENTAGE, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_ev_charge_level_supported, - value=_ev_charge_level_value, - ), - MazdaSensorEntityDescription( - key="ev_remaining_range", - translation_key="ev_remaining_range", - icon="mdi:ev-station", - device_class=SensorDeviceClass.DISTANCE, - native_unit_of_measurement=UnitOfLength.KILOMETERS, - state_class=SensorStateClass.MEASUREMENT, - is_supported=_ev_remaining_range_supported, - value=_ev_remaining_range_value, - ), -] - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the sensor platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - entities: list[SensorEntity] = [] - - for index, data in enumerate(coordinator.data): - for description in SENSOR_ENTITIES: - if description.is_supported(data): - entities.append( - MazdaSensorEntity(client, coordinator, index, description) - ) - - async_add_entities(entities) - - -class MazdaSensorEntity(MazdaEntity, SensorEntity): - """Representation of a Mazda vehicle sensor.""" - - entity_description: MazdaSensorEntityDescription - - def __init__(self, client, coordinator, index, description): - """Initialize Mazda sensor.""" - super().__init__(client, coordinator, index) - self.entity_description = description - - self._attr_unique_id = f"{self.vin}_{description.key}" - - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self.entity_description.value(self.data) diff --git a/homeassistant/components/mazda/services.yaml b/homeassistant/components/mazda/services.yaml deleted file mode 100644 index b401c01f3a3..00000000000 --- a/homeassistant/components/mazda/services.yaml +++ /dev/null @@ -1,30 +0,0 @@ -send_poi: - fields: - device_id: - required: true - selector: - device: - integration: mazda - latitude: - example: 12.34567 - required: true - selector: - number: - min: -90 - max: 90 - unit_of_measurement: ° - mode: box - longitude: - example: -34.56789 - required: true - selector: - number: - min: -180 - max: 180 - unit_of_measurement: ° - mode: box - poi_name: - example: Work - required: true - selector: - text: diff --git a/homeassistant/components/mazda/strings.json b/homeassistant/components/mazda/strings.json index 6c1214f76c6..1d0fedf3e97 100644 --- a/homeassistant/components/mazda/strings.json +++ b/homeassistant/components/mazda/strings.json @@ -1,139 +1,8 @@ { - "config": { - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" - }, - "error": { - "account_locked": "Account locked. Please try again later.", - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "step": { - "user": { - "data": { - "email": "[%key:common::config_flow::data::email%]", - "password": "[%key:common::config_flow::data::password%]", - "region": "Region" - }, - "description": "Please enter the email address and password you use to log into the MyMazda mobile app." - } - } - }, - "entity": { - "binary_sensor": { - "driver_door": { - "name": "Driver door" - }, - "passenger_door": { - "name": "Passenger door" - }, - "rear_left_door": { - "name": "Rear left door" - }, - "rear_right_door": { - "name": "Rear right door" - }, - "trunk": { - "name": "Trunk" - }, - "hood": { - "name": "Hood" - }, - "ev_plugged_in": { - "name": "Plugged in" - } - }, - "button": { - "start_engine": { - "name": "Start engine" - }, - "stop_engine": { - "name": "Stop engine" - }, - "turn_on_hazard_lights": { - "name": "Turn on hazard lights" - }, - "turn_off_hazard_lights": { - "name": "Turn off hazard lights" - }, - "refresh_vehicle_status": { - "name": "Refresh status" - } - }, - "climate": { - "climate": { - "name": "[%key:component::climate::title%]" - } - }, - "device_tracker": { - "device_tracker": { - "name": "[%key:component::device_tracker::title%]" - } - }, - "lock": { - "lock": { - "name": "[%key:component::lock::title%]" - } - }, - "sensor": { - "fuel_remaining_percentage": { - "name": "Fuel remaining percentage" - }, - "fuel_distance_remaining": { - "name": "Fuel distance remaining" - }, - "odometer": { - "name": "Odometer" - }, - "front_left_tire_pressure": { - "name": "Front left tire pressure" - }, - "front_right_tire_pressure": { - "name": "Front right tire pressure" - }, - "rear_left_tire_pressure": { - "name": "Rear left tire pressure" - }, - "rear_right_tire_pressure": { - "name": "Rear right tire pressure" - }, - "ev_charge_level": { - "name": "Charge level" - }, - "ev_remaining_range": { - "name": "Remaining range" - } - }, - "switch": { - "charging": { - "name": "Charging" - } - } - }, - "services": { - "send_poi": { - "name": "Send POI", - "description": "Sends a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle.", - "fields": { - "device_id": { - "name": "Vehicle", - "description": "The vehicle to send the GPS location to." - }, - "latitude": { - "name": "[%key:common::config_flow::data::latitude%]", - "description": "The latitude of the location to send." - }, - "longitude": { - "name": "[%key:common::config_flow::data::longitude%]", - "description": "The longitude of the location to send." - }, - "poi_name": { - "name": "POI name", - "description": "A friendly name for the location." - } - } + "issues": { + "integration_removed": { + "title": "The Mazda integration has been removed", + "description": "The Mazda integration has been removed from Home Assistant.\n\nThe library that Home Assistant uses to connect with their services, [has been taken offline by Mazda]({dmca}).\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Mazda integration entries]({entries})." } } } diff --git a/homeassistant/components/mazda/switch.py b/homeassistant/components/mazda/switch.py deleted file mode 100644 index 327d371769b..00000000000 --- a/homeassistant/components/mazda/switch.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Platform for Mazda switch integration.""" -from typing import Any - -from pymazda import Client as MazdaAPIClient - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import MazdaEntity -from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the switch platform.""" - client = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] - coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR] - - async_add_entities( - MazdaChargingSwitch(client, coordinator, index) - for index, data in enumerate(coordinator.data) - if data["isElectric"] - ) - - -class MazdaChargingSwitch(MazdaEntity, SwitchEntity): - """Class for the charging switch.""" - - _attr_translation_key = "charging" - _attr_icon = "mdi:ev-station" - - def __init__( - self, - client: MazdaAPIClient, - coordinator: DataUpdateCoordinator, - index: int, - ) -> None: - """Initialize Mazda charging switch.""" - super().__init__(client, coordinator, index) - - self._attr_unique_id = self.vin - - @property - def is_on(self): - """Return true if the vehicle is charging.""" - return self.data["evStatus"]["chargeInfo"]["charging"] - - async def refresh_status_and_write_state(self): - """Request a status update, retrieve it through the coordinator, and write the state.""" - await self.client.refresh_vehicle_status(self.vehicle_id) - - await self.coordinator.async_request_refresh() - - self.async_write_ha_state() - - async def async_turn_on(self, **kwargs: Any) -> None: - """Start charging the vehicle.""" - await self.client.start_charging(self.vehicle_id) - - await self.refresh_status_and_write_state() - - async def async_turn_off(self, **kwargs: Any) -> None: - """Stop charging the vehicle.""" - await self.client.stop_charging(self.vehicle_id) - - await self.refresh_status_and_write_state() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b9e1fcf5259..25d8a6f0d73 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -271,7 +271,6 @@ FLOWS = { "lyric", "mailgun", "matter", - "mazda", "meater", "medcom_ble", "melcloud", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9ee022473a2..9c53d998bcd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3262,12 +3262,6 @@ "config_flow": true, "iot_class": "local_push" }, - "mazda": { - "name": "Mazda Connected Services", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "meater": { "name": "Meater", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 6cb59b2527d..53dd9407863 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1847,9 +1847,6 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 -# homeassistant.components.mazda -pymazda==0.3.11 - # homeassistant.components.mediaroom pymediaroom==0.6.5.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 519767ca943..983c2fa0a5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1390,9 +1390,6 @@ pymailgunner==1.4 # homeassistant.components.firmata pymata-express==1.19 -# homeassistant.components.mazda -pymazda==0.3.11 - # homeassistant.components.melcloud pymelcloud==2.5.8 diff --git a/tests/components/mazda/__init__.py b/tests/components/mazda/__init__.py index 59b1d474140..cc3d81df4dd 100644 --- a/tests/components/mazda/__init__.py +++ b/tests/components/mazda/__init__.py @@ -1,80 +1 @@ """Tests for the Mazda Connected Services integration.""" - -import json -from unittest.mock import AsyncMock, MagicMock, Mock, patch - -from pymazda import Client as MazdaAPI - -from homeassistant.components.mazda.const import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client - -from tests.common import MockConfigEntry, load_fixture - -FIXTURE_USER_INPUT = { - CONF_EMAIL: "example@example.com", - CONF_PASSWORD: "password", - CONF_REGION: "MNAO", -} - - -async def init_integration( - hass: HomeAssistant, use_nickname=True, electric_vehicle=False -) -> MockConfigEntry: - """Set up the Mazda Connected Services integration in Home Assistant.""" - get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) - if not use_nickname: - get_vehicles_fixture[0].pop("nickname") - if electric_vehicle: - get_vehicles_fixture[0]["isElectric"] = True - - get_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_vehicle_status.json") - ) - get_ev_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_ev_vehicle_status.json") - ) - get_hvac_setting_fixture = json.loads(load_fixture("mazda/get_hvac_setting.json")) - - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - client_mock = MagicMock( - MazdaAPI( - FIXTURE_USER_INPUT[CONF_EMAIL], - FIXTURE_USER_INPUT[CONF_PASSWORD], - FIXTURE_USER_INPUT[CONF_REGION], - aiohttp_client.async_get_clientsession(hass), - ) - ) - client_mock.get_vehicles = AsyncMock(return_value=get_vehicles_fixture) - client_mock.get_vehicle_status = AsyncMock(return_value=get_vehicle_status_fixture) - client_mock.get_ev_vehicle_status = AsyncMock( - return_value=get_ev_vehicle_status_fixture - ) - client_mock.lock_doors = AsyncMock() - client_mock.unlock_doors = AsyncMock() - client_mock.send_poi = AsyncMock() - client_mock.start_charging = AsyncMock() - client_mock.start_engine = AsyncMock() - client_mock.stop_charging = AsyncMock() - client_mock.stop_engine = AsyncMock() - client_mock.turn_off_hazard_lights = AsyncMock() - client_mock.turn_on_hazard_lights = AsyncMock() - client_mock.refresh_vehicle_status = AsyncMock() - client_mock.get_hvac_setting = AsyncMock(return_value=get_hvac_setting_fixture) - client_mock.get_assumed_hvac_setting = Mock(return_value=get_hvac_setting_fixture) - client_mock.get_assumed_hvac_mode = Mock(return_value=True) - client_mock.set_hvac_setting = AsyncMock() - client_mock.turn_on_hvac = AsyncMock() - client_mock.turn_off_hvac = AsyncMock() - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI", - return_value=client_mock, - ), patch("homeassistant.components.mazda.MazdaAPI", return_value=client_mock): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return client_mock diff --git a/tests/components/mazda/fixtures/diagnostics_config_entry.json b/tests/components/mazda/fixtures/diagnostics_config_entry.json deleted file mode 100644 index 87f49bc29cb..00000000000 --- a/tests/components/mazda/fixtures/diagnostics_config_entry.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "info": { - "email": "**REDACTED**", - "password": "**REDACTED**", - "region": "MNAO" - }, - "data": [ - { - "vin": "**REDACTED**", - "id": "**REDACTED**", - "nickname": "My Mazda3", - "carlineCode": "M3S", - "carlineName": "MAZDA3 2.5 S SE AWD", - "modelYear": "2021", - "modelCode": "M3S SE XA", - "modelName": "W/ SELECT PKG AWD SDN", - "automaticTransmission": true, - "interiorColorCode": "BY3", - "interiorColorName": "BLACK", - "exteriorColorCode": "42M", - "exteriorColorName": "DEEP CRYSTAL BLUE MICA", - "isElectric": false, - "status": { - "lastUpdatedTimestamp": "20210123143809", - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "positionTimestamp": "20210123143808", - "fuelRemainingPercent": 87.0, - "fuelDistanceRemainingKm": 380.8, - "odometerKm": 2795.8, - "doors": { - "driverDoorOpen": false, - "passengerDoorOpen": true, - "rearLeftDoorOpen": false, - "rearRightDoorOpen": false, - "trunkOpen": false, - "hoodOpen": true, - "fuelLidOpen": false - }, - "doorLocks": { - "driverDoorUnlocked": false, - "passengerDoorUnlocked": false, - "rearLeftDoorUnlocked": false, - "rearRightDoorUnlocked": false - }, - "windows": { - "driverWindowOpen": false, - "passengerWindowOpen": false, - "rearLeftWindowOpen": false, - "rearRightWindowOpen": false - }, - "hazardLightsOn": false, - "tirePressure": { - "frontLeftTirePressurePsi": 35.0, - "frontRightTirePressurePsi": 35.0, - "rearLeftTirePressurePsi": 33.0, - "rearRightTirePressurePsi": 33.0 - } - } - } - ] -} diff --git a/tests/components/mazda/fixtures/diagnostics_device.json b/tests/components/mazda/fixtures/diagnostics_device.json deleted file mode 100644 index f2ddd658f70..00000000000 --- a/tests/components/mazda/fixtures/diagnostics_device.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "info": { - "email": "**REDACTED**", - "password": "**REDACTED**", - "region": "MNAO" - }, - "data": { - "vin": "**REDACTED**", - "id": "**REDACTED**", - "nickname": "My Mazda3", - "carlineCode": "M3S", - "carlineName": "MAZDA3 2.5 S SE AWD", - "modelYear": "2021", - "modelCode": "M3S SE XA", - "modelName": "W/ SELECT PKG AWD SDN", - "automaticTransmission": true, - "interiorColorCode": "BY3", - "interiorColorName": "BLACK", - "exteriorColorCode": "42M", - "exteriorColorName": "DEEP CRYSTAL BLUE MICA", - "isElectric": false, - "status": { - "lastUpdatedTimestamp": "20210123143809", - "latitude": "**REDACTED**", - "longitude": "**REDACTED**", - "positionTimestamp": "20210123143808", - "fuelRemainingPercent": 87.0, - "fuelDistanceRemainingKm": 380.8, - "odometerKm": 2795.8, - "doors": { - "driverDoorOpen": false, - "passengerDoorOpen": true, - "rearLeftDoorOpen": false, - "rearRightDoorOpen": false, - "trunkOpen": false, - "hoodOpen": true, - "fuelLidOpen": false - }, - "doorLocks": { - "driverDoorUnlocked": false, - "passengerDoorUnlocked": false, - "rearLeftDoorUnlocked": false, - "rearRightDoorUnlocked": false - }, - "windows": { - "driverWindowOpen": false, - "passengerWindowOpen": false, - "rearLeftWindowOpen": false, - "rearRightWindowOpen": false - }, - "hazardLightsOn": false, - "tirePressure": { - "frontLeftTirePressurePsi": 35.0, - "frontRightTirePressurePsi": 35.0, - "rearLeftTirePressurePsi": 33.0, - "rearRightTirePressurePsi": 33.0 - } - } - } -} diff --git a/tests/components/mazda/fixtures/get_ev_vehicle_status.json b/tests/components/mazda/fixtures/get_ev_vehicle_status.json deleted file mode 100644 index a577cab3054..00000000000 --- a/tests/components/mazda/fixtures/get_ev_vehicle_status.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "lastUpdatedTimestamp": "20210807083956", - "chargeInfo": { - "batteryLevelPercentage": 80, - "drivingRangeKm": 218, - "pluggedIn": true, - "charging": true, - "basicChargeTimeMinutes": 30, - "quickChargeTimeMinutes": 15, - "batteryHeaterAuto": true, - "batteryHeaterOn": true - }, - "hvacInfo": { - "hvacOn": true, - "frontDefroster": false, - "rearDefroster": false, - "interiorTemperatureCelsius": 15.1 - } -} diff --git a/tests/components/mazda/fixtures/get_hvac_setting.json b/tests/components/mazda/fixtures/get_hvac_setting.json deleted file mode 100644 index 3b95832ba65..00000000000 --- a/tests/components/mazda/fixtures/get_hvac_setting.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "temperature": 20, - "temperatureUnit": "C", - "frontDefroster": true, - "rearDefroster": false -} diff --git a/tests/components/mazda/fixtures/get_vehicle_status.json b/tests/components/mazda/fixtures/get_vehicle_status.json deleted file mode 100644 index 17fe86c642b..00000000000 --- a/tests/components/mazda/fixtures/get_vehicle_status.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "lastUpdatedTimestamp": "20210123143809", - "latitude": 1.234567, - "longitude": -2.345678, - "positionTimestamp": "20210123143808", - "fuelRemainingPercent": 87.0, - "fuelDistanceRemainingKm": 380.8, - "odometerKm": 2795.8, - "doors": { - "driverDoorOpen": false, - "passengerDoorOpen": true, - "rearLeftDoorOpen": false, - "rearRightDoorOpen": false, - "trunkOpen": false, - "hoodOpen": true, - "fuelLidOpen": false - }, - "doorLocks": { - "driverDoorUnlocked": false, - "passengerDoorUnlocked": false, - "rearLeftDoorUnlocked": false, - "rearRightDoorUnlocked": false - }, - "windows": { - "driverWindowOpen": false, - "passengerWindowOpen": false, - "rearLeftWindowOpen": false, - "rearRightWindowOpen": false - }, - "hazardLightsOn": false, - "tirePressure": { - "frontLeftTirePressurePsi": 35.0, - "frontRightTirePressurePsi": 35.0, - "rearLeftTirePressurePsi": 33.0, - "rearRightTirePressurePsi": 33.0 - } -} diff --git a/tests/components/mazda/fixtures/get_vehicles.json b/tests/components/mazda/fixtures/get_vehicles.json deleted file mode 100644 index a80a09f380a..00000000000 --- a/tests/components/mazda/fixtures/get_vehicles.json +++ /dev/null @@ -1,18 +0,0 @@ -[ - { - "vin": "JM000000000000000", - "id": 12345, - "nickname": "My Mazda3", - "carlineCode": "M3S", - "carlineName": "MAZDA3 2.5 S SE AWD", - "modelYear": "2021", - "modelCode": "M3S SE XA", - "modelName": "W/ SELECT PKG AWD SDN", - "automaticTransmission": true, - "interiorColorCode": "BY3", - "interiorColorName": "BLACK", - "exteriorColorCode": "42M", - "exteriorColorName": "DEEP CRYSTAL BLUE MICA", - "isElectric": false - } -] diff --git a/tests/components/mazda/test_binary_sensor.py b/tests/components/mazda/test_binary_sensor.py deleted file mode 100644 index d5bae776320..00000000000 --- a/tests/components/mazda/test_binary_sensor.py +++ /dev/null @@ -1,98 +0,0 @@ -"""The binary sensor tests for the Mazda Connected Services integration.""" -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.const import ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, ATTR_ICON -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - - -async def test_binary_sensors(hass: HomeAssistant) -> None: - """Test creation of the binary sensors.""" - await init_integration(hass) - - entity_registry = er.async_get(hass) - - # Driver Door - state = hass.states.get("binary_sensor.my_mazda3_driver_door") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Driver door" - assert state.attributes.get(ATTR_ICON) == "mdi:car-door" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "off" - entry = entity_registry.async_get("binary_sensor.my_mazda3_driver_door") - assert entry - assert entry.unique_id == "JM000000000000000_driver_door" - - # Passenger Door - state = hass.states.get("binary_sensor.my_mazda3_passenger_door") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Passenger door" - assert state.attributes.get(ATTR_ICON) == "mdi:car-door" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "on" - entry = entity_registry.async_get("binary_sensor.my_mazda3_passenger_door") - assert entry - assert entry.unique_id == "JM000000000000000_passenger_door" - - # Rear Left Door - state = hass.states.get("binary_sensor.my_mazda3_rear_left_door") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear left door" - assert state.attributes.get(ATTR_ICON) == "mdi:car-door" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "off" - entry = entity_registry.async_get("binary_sensor.my_mazda3_rear_left_door") - assert entry - assert entry.unique_id == "JM000000000000000_rear_left_door" - - # Rear Right Door - state = hass.states.get("binary_sensor.my_mazda3_rear_right_door") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear right door" - assert state.attributes.get(ATTR_ICON) == "mdi:car-door" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "off" - entry = entity_registry.async_get("binary_sensor.my_mazda3_rear_right_door") - assert entry - assert entry.unique_id == "JM000000000000000_rear_right_door" - - # Trunk - state = hass.states.get("binary_sensor.my_mazda3_trunk") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Trunk" - assert state.attributes.get(ATTR_ICON) == "mdi:car-back" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "off" - entry = entity_registry.async_get("binary_sensor.my_mazda3_trunk") - assert entry - assert entry.unique_id == "JM000000000000000_trunk" - - # Hood - state = hass.states.get("binary_sensor.my_mazda3_hood") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Hood" - assert state.attributes.get(ATTR_ICON) == "mdi:car" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.DOOR - assert state.state == "on" - entry = entity_registry.async_get("binary_sensor.my_mazda3_hood") - assert entry - assert entry.unique_id == "JM000000000000000_hood" - - -async def test_electric_vehicle_binary_sensors(hass: HomeAssistant) -> None: - """Test sensors which are specific to electric vehicles.""" - - await init_integration(hass, electric_vehicle=True) - - entity_registry = er.async_get(hass) - - # Plugged In - state = hass.states.get("binary_sensor.my_mazda3_plugged_in") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Plugged in" - assert state.attributes.get(ATTR_DEVICE_CLASS) == BinarySensorDeviceClass.PLUG - assert state.state == "on" - entry = entity_registry.async_get("binary_sensor.my_mazda3_plugged_in") - assert entry - assert entry.unique_id == "JM000000000000000_ev_plugged_in" diff --git a/tests/components/mazda/test_button.py b/tests/components/mazda/test_button.py deleted file mode 100644 index ba80c10b38d..00000000000 --- a/tests/components/mazda/test_button.py +++ /dev/null @@ -1,145 +0,0 @@ -"""The button tests for the Mazda Connected Services integration.""" - -from pymazda import MazdaException -import pytest - -from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er - -from . import init_integration - - -async def test_button_setup_non_electric_vehicle(hass: HomeAssistant) -> None: - """Test creation of button entities.""" - await init_integration(hass) - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get("button.my_mazda3_start_engine") - assert entry - assert entry.unique_id == "JM000000000000000_start_engine" - state = hass.states.get("button.my_mazda3_start_engine") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Start engine" - assert state.attributes.get(ATTR_ICON) == "mdi:engine" - - entry = entity_registry.async_get("button.my_mazda3_stop_engine") - assert entry - assert entry.unique_id == "JM000000000000000_stop_engine" - state = hass.states.get("button.my_mazda3_stop_engine") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Stop engine" - assert state.attributes.get(ATTR_ICON) == "mdi:engine-off" - - entry = entity_registry.async_get("button.my_mazda3_turn_on_hazard_lights") - assert entry - assert entry.unique_id == "JM000000000000000_turn_on_hazard_lights" - state = hass.states.get("button.my_mazda3_turn_on_hazard_lights") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn on hazard lights" - assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" - - entry = entity_registry.async_get("button.my_mazda3_turn_off_hazard_lights") - assert entry - assert entry.unique_id == "JM000000000000000_turn_off_hazard_lights" - state = hass.states.get("button.my_mazda3_turn_off_hazard_lights") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Turn off hazard lights" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:hazard-lights" - - # Since this is a non-electric vehicle, electric vehicle buttons should not be created - entry = entity_registry.async_get("button.my_mazda3_refresh_vehicle_status") - assert entry is None - state = hass.states.get("button.my_mazda3_refresh_vehicle_status") - assert state is None - - -async def test_button_setup_electric_vehicle(hass: HomeAssistant) -> None: - """Test creation of button entities for an electric vehicle.""" - await init_integration(hass, electric_vehicle=True) - - entity_registry = er.async_get(hass) - - entry = entity_registry.async_get("button.my_mazda3_refresh_status") - assert entry - assert entry.unique_id == "JM000000000000000_refresh_vehicle_status" - state = hass.states.get("button.my_mazda3_refresh_status") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Refresh status" - assert state.attributes.get(ATTR_ICON) == "mdi:refresh" - - -@pytest.mark.parametrize( - ("electric_vehicle", "entity_id_suffix"), - [ - (True, "start_engine"), - (True, "stop_engine"), - (True, "turn_on_hazard_lights"), - (True, "turn_off_hazard_lights"), - (False, "refresh_status"), - ], -) -async def test_button_not_created( - hass: HomeAssistant, electric_vehicle, entity_id_suffix -) -> None: - """Test that button entities are not created when they should not be.""" - await init_integration(hass, electric_vehicle=electric_vehicle) - - entity_registry = er.async_get(hass) - - entity_id = f"button.my_mazda3_{entity_id_suffix}" - entry = entity_registry.async_get(entity_id) - assert entry is None - state = hass.states.get(entity_id) - assert state is None - - -@pytest.mark.parametrize( - ("electric_vehicle", "entity_id_suffix", "api_method_name"), - [ - (False, "start_engine", "start_engine"), - (False, "stop_engine", "stop_engine"), - (False, "turn_on_hazard_lights", "turn_on_hazard_lights"), - (False, "turn_off_hazard_lights", "turn_off_hazard_lights"), - (True, "refresh_status", "refresh_vehicle_status"), - ], -) -async def test_button_press( - hass: HomeAssistant, electric_vehicle, entity_id_suffix, api_method_name -) -> None: - """Test pressing the button entities.""" - client_mock = await init_integration(hass, electric_vehicle=electric_vehicle) - - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: f"button.my_mazda3_{entity_id_suffix}"}, - blocking=True, - ) - await hass.async_block_till_done() - - api_method = getattr(client_mock, api_method_name) - api_method.assert_called_once_with(12345) - - -async def test_button_press_error(hass: HomeAssistant) -> None: - """Test the Mazda API raising an error when a button entity is pressed.""" - client_mock = await init_integration(hass) - - client_mock.start_engine.side_effect = MazdaException("Test error") - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.my_mazda3_start_engine"}, - blocking=True, - ) - await hass.async_block_till_done() - - assert str(err.value) == "Test error" diff --git a/tests/components/mazda/test_climate.py b/tests/components/mazda/test_climate.py deleted file mode 100644 index ef3840f5cee..00000000000 --- a/tests/components/mazda/test_climate.py +++ /dev/null @@ -1,341 +0,0 @@ -"""The climate tests for the Mazda Connected Services integration.""" -import json -from unittest.mock import patch - -import pytest - -from homeassistant.components.climate import ( - ATTR_HVAC_MODE, - ATTR_PRESET_MODE, - DOMAIN as CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - SERVICE_SET_PRESET_MODE, - SERVICE_SET_TEMPERATURE, -) -from homeassistant.components.climate.const import ( - ATTR_CURRENT_TEMPERATURE, - ATTR_HVAC_MODES, - ATTR_MAX_TEMP, - ATTR_MIN_TEMP, - ATTR_PRESET_MODES, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.components.mazda.climate import ( - PRESET_DEFROSTER_FRONT, - PRESET_DEFROSTER_FRONT_AND_REAR, - PRESET_DEFROSTER_OFF, - PRESET_DEFROSTER_REAR, -) -from homeassistant.components.mazda.const import DOMAIN -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_SUPPORTED_FEATURES, - ATTR_TEMPERATURE, - CONF_EMAIL, - CONF_PASSWORD, - CONF_REGION, - UnitOfTemperature, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM - -from . import init_integration - -from tests.common import MockConfigEntry, load_fixture - - -async def test_climate_setup(hass: HomeAssistant) -> None: - """Test the setup of the climate entity.""" - await init_integration(hass, electric_vehicle=True) - - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("climate.my_mazda3_climate") - assert entry - assert entry.unique_id == "JM000000000000000" - - state = hass.states.get("climate.my_mazda3_climate") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Climate" - - -@pytest.mark.parametrize( - ( - "region", - "hvac_on", - "target_temperature", - "temperature_unit", - "front_defroster", - "rear_defroster", - "current_temperature_celsius", - "expected_hvac_mode", - "expected_preset_mode", - "expected_min_temp", - "expected_max_temp", - ), - [ - # Test with HVAC off - ( - "MNAO", - False, - 20, - "C", - False, - False, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_OFF, - 15.5, - 28.5, - ), - # Test with HVAC on - ( - "MNAO", - True, - 20, - "C", - False, - False, - 22, - HVACMode.HEAT_COOL, - PRESET_DEFROSTER_OFF, - 15.5, - 28.5, - ), - # Test with front defroster on - ( - "MNAO", - False, - 20, - "C", - True, - False, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_FRONT, - 15.5, - 28.5, - ), - # Test with rear defroster on - ( - "MNAO", - False, - 20, - "C", - False, - True, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_REAR, - 15.5, - 28.5, - ), - # Test with front and rear defrosters on - ( - "MNAO", - False, - 20, - "C", - True, - True, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_FRONT_AND_REAR, - 15.5, - 28.5, - ), - # Test with temperature unit F - ( - "MNAO", - False, - 70, - "F", - False, - False, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_OFF, - 61.0, - 83.0, - ), - # Test with Japan region (uses different min/max temp settings) - ( - "MJO", - False, - 20, - "C", - False, - False, - 22, - HVACMode.OFF, - PRESET_DEFROSTER_OFF, - 18.5, - 31.5, - ), - ], -) -async def test_climate_state( - hass: HomeAssistant, - region, - hvac_on, - target_temperature, - temperature_unit, - front_defroster, - rear_defroster, - current_temperature_celsius, - expected_hvac_mode, - expected_preset_mode, - expected_min_temp, - expected_max_temp, -) -> None: - """Test getting the state of the climate entity.""" - if temperature_unit == "F": - hass.config.units = US_CUSTOMARY_SYSTEM - - get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) - get_vehicles_fixture[0]["isElectric"] = True - get_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_vehicle_status.json") - ) - get_ev_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_ev_vehicle_status.json") - ) - get_ev_vehicle_status_fixture["hvacInfo"][ - "interiorTemperatureCelsius" - ] = current_temperature_celsius - get_hvac_setting_fixture = { - "temperature": target_temperature, - "temperatureUnit": temperature_unit, - "frontDefroster": front_defroster, - "rearDefroster": rear_defroster, - } - - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - return_value=get_vehicles_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicle_status", - return_value=get_vehicle_status_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_ev_vehicle_status", - return_value=get_ev_vehicle_status_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_assumed_hvac_mode", - return_value=hvac_on, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_assumed_hvac_setting", - return_value=get_hvac_setting_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_hvac_setting", - return_value=get_hvac_setting_fixture, - ): - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_EMAIL: "example@example.com", - CONF_PASSWORD: "password", - CONF_REGION: region, - }, - ) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("climate.my_mazda3_climate") - assert state - assert state.state == expected_hvac_mode - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Climate" - assert ( - state.attributes.get(ATTR_SUPPORTED_FEATURES) - == ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - assert state.attributes.get(ATTR_HVAC_MODES) == [HVACMode.HEAT_COOL, HVACMode.OFF] - assert state.attributes.get(ATTR_PRESET_MODES) == [ - PRESET_DEFROSTER_OFF, - PRESET_DEFROSTER_FRONT, - PRESET_DEFROSTER_REAR, - PRESET_DEFROSTER_FRONT_AND_REAR, - ] - assert state.attributes.get(ATTR_MIN_TEMP) == expected_min_temp - assert state.attributes.get(ATTR_MAX_TEMP) == expected_max_temp - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == round( - hass.config.units.temperature( - current_temperature_celsius, UnitOfTemperature.CELSIUS - ) - ) - assert state.attributes.get(ATTR_TEMPERATURE) == target_temperature - assert state.attributes.get(ATTR_PRESET_MODE) == expected_preset_mode - - -@pytest.mark.parametrize( - ("hvac_mode", "api_method"), - [ - (HVACMode.HEAT_COOL, "turn_on_hvac"), - (HVACMode.OFF, "turn_off_hvac"), - ], -) -async def test_set_hvac_mode(hass: HomeAssistant, hvac_mode, api_method) -> None: - """Test turning on and off the HVAC system.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - {ATTR_ENTITY_ID: "climate.my_mazda3_climate", ATTR_HVAC_MODE: hvac_mode}, - blocking=True, - ) - await hass.async_block_till_done() - - getattr(client_mock, api_method).assert_called_once_with(12345) - - -async def test_set_target_temperature(hass: HomeAssistant) -> None: - """Test setting the target temperature of the climate entity.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: "climate.my_mazda3_climate", ATTR_TEMPERATURE: 22}, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.set_hvac_setting.assert_called_once_with(12345, 22, "C", True, False) - - -@pytest.mark.parametrize( - ("preset_mode", "front_defroster", "rear_defroster"), - [ - (PRESET_DEFROSTER_OFF, False, False), - (PRESET_DEFROSTER_FRONT, True, False), - (PRESET_DEFROSTER_REAR, False, True), - (PRESET_DEFROSTER_FRONT_AND_REAR, True, True), - ], -) -async def test_set_preset_mode( - hass: HomeAssistant, preset_mode, front_defroster, rear_defroster -) -> None: - """Test turning on and off the front and rear defrosters.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - { - ATTR_ENTITY_ID: "climate.my_mazda3_climate", - ATTR_PRESET_MODE: preset_mode, - }, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.set_hvac_setting.assert_called_once_with( - 12345, 20, "C", front_defroster, rear_defroster - ) diff --git a/tests/components/mazda/test_config_flow.py b/tests/components/mazda/test_config_flow.py deleted file mode 100644 index da7f0369079..00000000000 --- a/tests/components/mazda/test_config_flow.py +++ /dev/null @@ -1,423 +0,0 @@ -"""Test the Mazda Connected Services config flow.""" -from unittest.mock import patch - -import aiohttp - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.mazda.config_flow import ( - MazdaAccountLockedException, - MazdaAuthenticationException, -) -from homeassistant.components.mazda.const import DOMAIN -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - -FIXTURE_USER_INPUT = { - CONF_EMAIL: "example@example.com", - CONF_PASSWORD: "password", - CONF_REGION: "MNAO", -} -FIXTURE_USER_INPUT_REAUTH = { - CONF_EMAIL: "example@example.com", - CONF_PASSWORD: "password_fixed", - CONF_REGION: "MNAO", -} -FIXTURE_USER_INPUT_REAUTH_CHANGED_EMAIL = { - CONF_EMAIL: "example2@example.com", - CONF_PASSWORD: "password_fixed", - CONF_REGION: "MNAO", -} - - -async def test_form(hass: HomeAssistant) -> None: - """Test the entire flow.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == FIXTURE_USER_INPUT[CONF_EMAIL] - assert result2["data"] == FIXTURE_USER_INPUT - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_account_already_exists(hass: HomeAssistant) -> None: - """Test account already exists.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - return_value=True, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=MazdaAuthenticationException("Failed to authenticate"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_account_locked(hass: HomeAssistant) -> None: - """Test we handle account locked error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=MazdaAccountLockedException("Account locked"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "account_locked"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=aiohttp.ClientError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_error(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT, - ) - - assert result2["type"] == "form" - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth_flow(hass: HomeAssistant) -> None: - """Test reauth works.""" - - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=MazdaAuthenticationException("Failed to authenticate"), - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - await hass.config_entries.async_setup(mock_config.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - assert result["errors"] == {} - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - return_value=True, - ), patch("homeassistant.components.mazda.async_setup_entry", return_value=True): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - - -async def test_reauth_authorization_error(hass: HomeAssistant) -> None: - """Test we show user form on authorization error.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=MazdaAuthenticationException("Failed to authenticate"), - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_reauth_account_locked(hass: HomeAssistant) -> None: - """Test we show user form on account_locked error.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=MazdaAccountLockedException("Account locked"), - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "account_locked"} - - -async def test_reauth_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on connection error.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=aiohttp.ClientError, - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_reauth_unknown_error(hass: HomeAssistant) -> None: - """Test we show user form on unknown error.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - side_effect=Exception, - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH, - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "unknown"} - - -async def test_reauth_user_has_new_email_address(hass: HomeAssistant) -> None: - """Test reauth with a new email address but same account.""" - mock_config = MockConfigEntry( - domain=DOMAIN, - unique_id=FIXTURE_USER_INPUT[CONF_EMAIL], - data=FIXTURE_USER_INPUT, - ) - mock_config.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.config_flow.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": mock_config.entry_id, - }, - data=FIXTURE_USER_INPUT, - ) - - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "user" - - # Change the email and ensure the entry and its unique id gets - # updated in the event the user has changed their email with mazda - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - FIXTURE_USER_INPUT_REAUTH_CHANGED_EMAIL, - ) - await hass.async_block_till_done() - - assert ( - mock_config.unique_id == FIXTURE_USER_INPUT_REAUTH_CHANGED_EMAIL[CONF_EMAIL] - ) - assert result2["type"] == data_entry_flow.FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" diff --git a/tests/components/mazda/test_device_tracker.py b/tests/components/mazda/test_device_tracker.py deleted file mode 100644 index 72168ef3c27..00000000000 --- a/tests/components/mazda/test_device_tracker.py +++ /dev/null @@ -1,30 +0,0 @@ -"""The device tracker tests for the Mazda Connected Services integration.""" -from homeassistant.components.device_tracker import ATTR_SOURCE_TYPE, SourceType -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_LATITUDE, - ATTR_LONGITUDE, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - - -async def test_device_tracker(hass: HomeAssistant) -> None: - """Test creation of the device tracker.""" - await init_integration(hass) - - entity_registry = er.async_get(hass) - - state = hass.states.get("device_tracker.my_mazda3_device_tracker") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Device tracker" - assert state.attributes.get(ATTR_ICON) == "mdi:car" - assert state.attributes.get(ATTR_LATITUDE) == 1.234567 - assert state.attributes.get(ATTR_LONGITUDE) == -2.345678 - assert state.attributes.get(ATTR_SOURCE_TYPE) == SourceType.GPS - entry = entity_registry.async_get("device_tracker.my_mazda3_device_tracker") - assert entry - assert entry.unique_id == "JM000000000000000" diff --git a/tests/components/mazda/test_diagnostics.py b/tests/components/mazda/test_diagnostics.py deleted file mode 100644 index 9dccf8f6afd..00000000000 --- a/tests/components/mazda/test_diagnostics.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Test Mazda diagnostics.""" -import json - -import pytest - -from homeassistant.components.mazda.const import DATA_COORDINATOR, DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr - -from . import init_integration - -from tests.common import load_fixture -from tests.components.diagnostics import ( - get_diagnostics_for_config_entry, - get_diagnostics_for_device, -) -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test config entry diagnostics.""" - await init_integration(hass) - assert hass.data[DOMAIN] - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - - diagnostics_fixture = json.loads( - load_fixture("mazda/diagnostics_config_entry.json") - ) - - assert ( - await get_diagnostics_for_config_entry(hass, hass_client, config_entry) - == diagnostics_fixture - ) - - -async def test_device_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test device diagnostics.""" - await init_integration(hass) - assert hass.data[DOMAIN] - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - assert reg_device is not None - - diagnostics_fixture = json.loads(load_fixture("mazda/diagnostics_device.json")) - - assert ( - await get_diagnostics_for_device(hass, hass_client, config_entry, reg_device) - == diagnostics_fixture - ) - - -async def test_device_diagnostics_vehicle_not_found( - hass: HomeAssistant, hass_client: ClientSessionGenerator -) -> None: - """Test device diagnostics when the vehicle cannot be found.""" - await init_integration(hass) - assert hass.data[DOMAIN] - - config_entry = hass.config_entries.async_entries(DOMAIN)[0] - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - assert reg_device is not None - - # Remove vehicle info from hass.data so that vehicle will not be found - hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR].data = [] - - with pytest.raises(AssertionError): - await get_diagnostics_for_device(hass, hass_client, config_entry, reg_device) diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 3556f687989..5d15f01389b 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -1,365 +1,50 @@ """Tests for the Mazda Connected Services integration.""" -from datetime import timedelta -import json -from unittest.mock import patch -from pymazda import MazdaAuthenticationException, MazdaException -import pytest -import voluptuous as vol - -from homeassistant.components.mazda.const import DOMAIN +from homeassistant.components.mazda import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - CONF_EMAIL, - CONF_PASSWORD, - CONF_REGION, - STATE_UNAVAILABLE, -) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr -from homeassistant.util import dt as dt_util +from homeassistant.helpers import issue_registry as ir -from . import init_integration - -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture - -FIXTURE_USER_INPUT = { - CONF_EMAIL: "example@example.com", - CONF_PASSWORD: "password", - CONF_REGION: "MNAO", -} +from tests.common import MockConfigEntry -async def test_config_entry_not_ready(hass: HomeAssistant) -> None: - """Test the Mazda configuration entry not ready.""" - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - side_effect=MazdaException("Unknown error"), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_init_auth_failure(hass: HomeAssistant) -> None: - """Test auth failure during setup.""" - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - side_effect=MazdaAuthenticationException("Login failed"), - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.SETUP_ERROR - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "user" - - -async def test_update_auth_failure(hass: HomeAssistant) -> None: - """Test auth failure during data update.""" - get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) - get_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_vehicle_status.json") - ) - - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - return_value=get_vehicles_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicle_status", - return_value=get_vehicle_status_fixture, - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - with patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - side_effect=MazdaAuthenticationException("Login failed"), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181)) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "user" - - -async def test_update_general_failure(hass: HomeAssistant) -> None: - """Test general failure during data update.""" - get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) - get_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_vehicle_status.json") - ) - - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - return_value=get_vehicles_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicle_status", - return_value=get_vehicle_status_fixture, - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - with patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - side_effect=Exception("Unknown exception"), - ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181)) - await hass.async_block_till_done() - - entity = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage") - assert entity is not None - assert entity.state == STATE_UNAVAILABLE - - -async def test_unload_config_entry(hass: HomeAssistant) -> None: - """Test the Mazda configuration entry unloading.""" - await init_integration(hass) - assert hass.data[DOMAIN] - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - await hass.config_entries.async_unload(entries[0].entry_id) - await hass.async_block_till_done() - assert entries[0].state is ConfigEntryState.NOT_LOADED - - -async def test_init_electric_vehicle(hass: HomeAssistant) -> None: - """Test initialization of the integration with an electric vehicle.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - client_mock.get_vehicles.assert_called_once() - client_mock.get_vehicle_status.assert_called_once() - client_mock.get_ev_vehicle_status.assert_called_once() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].state is ConfigEntryState.LOADED - - -async def test_device_nickname(hass: HomeAssistant) -> None: - """Test creation of the device when vehicle has a nickname.""" - await init_integration(hass, use_nickname=True) - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - - assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD" - assert reg_device.manufacturer == "Mazda" - assert reg_device.name == "My Mazda3" - - -async def test_device_no_nickname(hass: HomeAssistant) -> None: - """Test creation of the device when vehicle has no nickname.""" - await init_integration(hass, use_nickname=False) - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - - assert reg_device.model == "2021 MAZDA3 2.5 S SE AWD" - assert reg_device.manufacturer == "Mazda" - assert reg_device.name == "2021 MAZDA3 2.5 S SE AWD" - - -@pytest.mark.parametrize( - ("service", "service_data", "expected_args"), - [ - ( - "send_poi", - {"latitude": 1.2345, "longitude": 2.3456, "poi_name": "Work"}, - [12345, 1.2345, 2.3456, "Work"], - ), - ], -) -async def test_services( - hass: HomeAssistant, service, service_data, expected_args +async def test_mazda_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry ) -> None: - """Test service calls.""" - client_mock = await init_integration(hass) - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, + """Test the Mazda configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, ) - device_id = reg_device.id + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED - service_data["device_id"] = device_id - - await hass.services.async_call(DOMAIN, service, service_data, blocking=True) + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) await hass.async_block_till_done() - api_method = getattr(client_mock, service) - api_method.assert_called_once_with(*expected_args) + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() -async def test_service_invalid_device_id(hass: HomeAssistant) -> None: - """Test service call when the specified device ID is invalid.""" - await init_integration(hass) + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) - with pytest.raises(vol.error.MultipleInvalid) as err: - await hass.services.async_call( - DOMAIN, - "send_poi", - { - "device_id": "invalid", - "latitude": 1.2345, - "longitude": 6.7890, - "poi_name": "poi_name", - }, - blocking=True, - ) - await hass.async_block_till_done() + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() - assert "Invalid device ID" in str(err.value) - - -async def test_service_device_id_not_mazda_vehicle(hass: HomeAssistant) -> None: - """Test service call when the specified device ID is not the device ID of a Mazda vehicle.""" - await init_integration(hass) - - device_registry = dr.async_get(hass) - # Create another device and pass its device ID. - # Service should fail because device is from wrong domain. - other_config_entry = MockConfigEntry() - other_config_entry.add_to_hass(hass) - other_device = device_registry.async_get_or_create( - config_entry_id=other_config_entry.entry_id, - identifiers={("OTHER_INTEGRATION", "ID_FROM_OTHER_INTEGRATION")}, - ) - - with pytest.raises(vol.error.MultipleInvalid) as err: - await hass.services.async_call( - DOMAIN, - "send_poi", - { - "device_id": other_device.id, - "latitude": 1.2345, - "longitude": 6.7890, - "poi_name": "poi_name", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert "Device ID is not a Mazda vehicle" in str(err.value) - - -async def test_service_vehicle_id_not_found(hass: HomeAssistant) -> None: - """Test service call when the vehicle ID is not found.""" - await init_integration(hass) - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - device_id = reg_device.id - - entries = hass.config_entries.async_entries(DOMAIN) - entry_id = entries[0].entry_id - - # Remove vehicle info from hass.data so that vehicle ID will not be found - hass.data[DOMAIN][entry_id]["vehicles"] = [] - - with pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, - "send_poi", - { - "device_id": device_id, - "latitude": 1.2345, - "longitude": 6.7890, - "poi_name": "poi_name", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert str(err.value) == "Vehicle ID not found" - - -async def test_service_mazda_api_error(hass: HomeAssistant) -> None: - """Test the Mazda API raising an error when a service is called.""" - get_vehicles_fixture = json.loads(load_fixture("mazda/get_vehicles.json")) - get_vehicle_status_fixture = json.loads( - load_fixture("mazda/get_vehicle_status.json") - ) - - with patch( - "homeassistant.components.mazda.MazdaAPI.validate_credentials", - return_value=True, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicles", - return_value=get_vehicles_fixture, - ), patch( - "homeassistant.components.mazda.MazdaAPI.get_vehicle_status", - return_value=get_vehicle_status_fixture, - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=FIXTURE_USER_INPUT) - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - device_registry = dr.async_get(hass) - reg_device = device_registry.async_get_device( - identifiers={(DOMAIN, "JM000000000000000")}, - ) - device_id = reg_device.id - - with patch( - "homeassistant.components.mazda.MazdaAPI.send_poi", - side_effect=MazdaException("Test error"), - ), pytest.raises(HomeAssistantError) as err: - await hass.services.async_call( - DOMAIN, - "send_poi", - { - "device_id": device_id, - "latitude": 1.2345, - "longitude": 6.7890, - "poi_name": "poi_name", - }, - blocking=True, - ) - await hass.async_block_till_done() - - assert str(err.value) == "Test error" + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None diff --git a/tests/components/mazda/test_lock.py b/tests/components/mazda/test_lock.py deleted file mode 100644 index 0de4573c5f8..00000000000 --- a/tests/components/mazda/test_lock.py +++ /dev/null @@ -1,58 +0,0 @@ -"""The lock tests for the Mazda Connected Services integration.""" -from homeassistant.components.lock import ( - DOMAIN as LOCK_DOMAIN, - SERVICE_LOCK, - SERVICE_UNLOCK, - STATE_LOCKED, -) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - - -async def test_lock_setup(hass: HomeAssistant) -> None: - """Test locking and unlocking the vehicle.""" - await init_integration(hass) - - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("lock.my_mazda3_lock") - assert entry - assert entry.unique_id == "JM000000000000000" - - state = hass.states.get("lock.my_mazda3_lock") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Lock" - - assert state.state == STATE_LOCKED - - -async def test_locking(hass: HomeAssistant) -> None: - """Test locking the vehicle.""" - client_mock = await init_integration(hass) - - await hass.services.async_call( - LOCK_DOMAIN, - SERVICE_LOCK, - {ATTR_ENTITY_ID: "lock.my_mazda3_lock"}, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.lock_doors.assert_called_once() - - -async def test_unlocking(hass: HomeAssistant) -> None: - """Test unlocking the vehicle.""" - client_mock = await init_integration(hass) - - await hass.services.async_call( - LOCK_DOMAIN, - SERVICE_UNLOCK, - {ATTR_ENTITY_ID: "lock.my_mazda3_lock"}, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.unlock_doors.assert_called_once() diff --git a/tests/components/mazda/test_sensor.py b/tests/components/mazda/test_sensor.py deleted file mode 100644 index 0fb92c34baf..00000000000 --- a/tests/components/mazda/test_sensor.py +++ /dev/null @@ -1,195 +0,0 @@ -"""The sensor tests for the Mazda Connected Services integration.""" -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_FRIENDLY_NAME, - ATTR_ICON, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - UnitOfLength, - UnitOfPressure, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM - -from . import init_integration - - -async def test_sensors(hass: HomeAssistant) -> None: - """Test creation of the sensors.""" - await init_integration(hass) - - entity_registry = er.async_get(hass) - - # Fuel Remaining Percentage - state = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "My Mazda3 Fuel remaining percentage" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "87.0" - entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage") - assert entry - assert entry.unique_id == "JM000000000000000_fuel_remaining_percentage" - - # Fuel Distance Remaining - state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Fuel distance remaining" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:gas-station" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.KILOMETERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "381" - entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining") - assert entry - assert entry.unique_id == "JM000000000000000_fuel_distance_remaining" - - # Odometer - state = hass.states.get("sensor.my_mazda3_odometer") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Odometer" - assert state.attributes.get(ATTR_ICON) == "mdi:speedometer" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.KILOMETERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING - assert state.state == "2795" - entry = entity_registry.async_get("sensor.my_mazda3_odometer") - assert entry - assert entry.unique_id == "JM000000000000000_odometer" - - # Front Left Tire Pressure - state = hass.states.get("sensor.my_mazda3_front_left_tire_pressure") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Front left tire pressure" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.KPA - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "241" - entry = entity_registry.async_get("sensor.my_mazda3_front_left_tire_pressure") - assert entry - assert entry.unique_id == "JM000000000000000_front_left_tire_pressure" - - # Front Right Tire Pressure - state = hass.states.get("sensor.my_mazda3_front_right_tire_pressure") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) - == "My Mazda3 Front right tire pressure" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.KPA - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "241" - entry = entity_registry.async_get("sensor.my_mazda3_front_right_tire_pressure") - assert entry - assert entry.unique_id == "JM000000000000000_front_right_tire_pressure" - - # Rear Left Tire Pressure - state = hass.states.get("sensor.my_mazda3_rear_left_tire_pressure") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear left tire pressure" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.KPA - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "228" - entry = entity_registry.async_get("sensor.my_mazda3_rear_left_tire_pressure") - assert entry - assert entry.unique_id == "JM000000000000000_rear_left_tire_pressure" - - # Rear Right Tire Pressure - state = hass.states.get("sensor.my_mazda3_rear_right_tire_pressure") - assert state - assert ( - state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Rear right tire pressure" - ) - assert state.attributes.get(ATTR_ICON) == "mdi:car-tire-alert" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.PRESSURE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPressure.KPA - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "228" - entry = entity_registry.async_get("sensor.my_mazda3_rear_right_tire_pressure") - assert entry - assert entry.unique_id == "JM000000000000000_rear_right_tire_pressure" - - -async def test_sensors_us_customary_units(hass: HomeAssistant) -> None: - """Test that the sensors work properly with US customary units.""" - hass.config.units = US_CUSTOMARY_SYSTEM - - await init_integration(hass) - - # In the US, miles are used for vehicle odometers. - # These tests verify that the unit conversion logic for the distance - # sensor device class automatically converts the unit to miles. - - # Fuel Distance Remaining - state = hass.states.get("sensor.my_mazda3_fuel_distance_remaining") - assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.MILES - assert state.state == "237" - - # Odometer - state = hass.states.get("sensor.my_mazda3_odometer") - assert state - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.MILES - assert state.state == "1737" - - -async def test_electric_vehicle_sensors(hass: HomeAssistant) -> None: - """Test sensors which are specific to electric vehicles.""" - - await init_integration(hass, electric_vehicle=True) - - entity_registry = er.async_get(hass) - - # Fuel Remaining Percentage should not exist for an electric vehicle - entry = entity_registry.async_get("sensor.my_mazda3_fuel_remaining_percentage") - assert entry is None - - # Fuel Distance Remaining should not exist for an electric vehicle - entry = entity_registry.async_get("sensor.my_mazda3_fuel_distance_remaining") - assert entry is None - - # Charge Level - state = hass.states.get("sensor.my_mazda3_charge_level") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Charge level" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.BATTERY - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "80" - entry = entity_registry.async_get("sensor.my_mazda3_charge_level") - assert entry - assert entry.unique_id == "JM000000000000000_ev_charge_level" - - # Remaining Range - state = hass.states.get("sensor.my_mazda3_remaining_range") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Remaining range" - assert state.attributes.get(ATTR_ICON) == "mdi:ev-station" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DISTANCE - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfLength.KILOMETERS - assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT - assert state.state == "218" - entry = entity_registry.async_get("sensor.my_mazda3_remaining_range") - assert entry - assert entry.unique_id == "JM000000000000000_ev_remaining_range" diff --git a/tests/components/mazda/test_switch.py b/tests/components/mazda/test_switch.py deleted file mode 100644 index a2d8ca649f3..00000000000 --- a/tests/components/mazda/test_switch.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The switch tests for the Mazda Connected Services integration.""" -from homeassistant.components.switch import ( - DOMAIN as SWITCH_DOMAIN, - SERVICE_TURN_OFF, - SERVICE_TURN_ON, - STATE_ON, -) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - -from . import init_integration - - -async def test_switch_setup(hass: HomeAssistant) -> None: - """Test setup of the switch entity.""" - await init_integration(hass, electric_vehicle=True) - - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("switch.my_mazda3_charging") - assert entry - assert entry.unique_id == "JM000000000000000" - - state = hass.states.get("switch.my_mazda3_charging") - assert state - assert state.attributes.get(ATTR_FRIENDLY_NAME) == "My Mazda3 Charging" - assert state.attributes.get(ATTR_ICON) == "mdi:ev-station" - - assert state.state == STATE_ON - - -async def test_start_charging(hass: HomeAssistant) -> None: - """Test turning on the charging switch.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - client_mock.reset_mock() - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.my_mazda3_charging"}, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.start_charging.assert_called_once() - client_mock.refresh_vehicle_status.assert_called_once() - client_mock.get_vehicle_status.assert_called_once() - client_mock.get_ev_vehicle_status.assert_called_once() - - -async def test_stop_charging(hass: HomeAssistant) -> None: - """Test turning off the charging switch.""" - client_mock = await init_integration(hass, electric_vehicle=True) - - client_mock.reset_mock() - - await hass.services.async_call( - SWITCH_DOMAIN, - SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.my_mazda3_charging"}, - blocking=True, - ) - await hass.async_block_till_done() - - client_mock.stop_charging.assert_called_once() - client_mock.refresh_vehicle_status.assert_called_once() - client_mock.get_vehicle_status.assert_called_once() - client_mock.get_ev_vehicle_status.assert_called_once() From 5730cb1e851b9b9153f23316c3b789e3860d2983 Mon Sep 17 00:00:00 2001 From: Guy Shefer Date: Thu, 12 Oct 2023 14:15:25 +0300 Subject: [PATCH 387/968] Add Tami4 Integration (#90056) Co-authored-by: Franck Nijhof Co-authored-by: Robert Resch --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/tami4/__init__.py | 46 +++++ homeassistant/components/tami4/config_flow.py | 95 ++++++++++ homeassistant/components/tami4/const.py | 6 + homeassistant/components/tami4/coordinator.py | 61 +++++++ homeassistant/components/tami4/entity.py | 33 ++++ homeassistant/components/tami4/manifest.json | 9 + homeassistant/components/tami4/sensor.py | 118 +++++++++++++ homeassistant/components/tami4/strings.json | 54 ++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/tami4/__init__.py | 1 + tests/components/tami4/conftest.py | 125 ++++++++++++++ tests/components/tami4/test_config_flow.py | 163 ++++++++++++++++++ tests/components/tami4/test_init.py | 59 +++++++ 19 files changed, 796 insertions(+) create mode 100644 homeassistant/components/tami4/__init__.py create mode 100644 homeassistant/components/tami4/config_flow.py create mode 100644 homeassistant/components/tami4/const.py create mode 100644 homeassistant/components/tami4/coordinator.py create mode 100644 homeassistant/components/tami4/entity.py create mode 100644 homeassistant/components/tami4/manifest.json create mode 100644 homeassistant/components/tami4/sensor.py create mode 100644 homeassistant/components/tami4/strings.json create mode 100644 tests/components/tami4/__init__.py create mode 100644 tests/components/tami4/conftest.py create mode 100644 tests/components/tami4/test_config_flow.py create mode 100644 tests/components/tami4/test_init.py diff --git a/.strict-typing b/.strict-typing index f59323ef76c..783395ff926 100644 --- a/.strict-typing +++ b/.strict-typing @@ -328,6 +328,7 @@ homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* homeassistant.components.tag.* homeassistant.components.tailscale.* +homeassistant.components.tami4.* homeassistant.components.tautulli.* homeassistant.components.tcp.* homeassistant.components.text.* diff --git a/CODEOWNERS b/CODEOWNERS index 880dd552cbc..d35d1d964fb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1265,6 +1265,8 @@ build.json @home-assistant/supervisor /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck /tests/components/tailscale/ @frenck +/homeassistant/components/tami4/ @Guy293 +/tests/components/tami4/ @Guy293 /homeassistant/components/tankerkoenig/ @guillempages @mib1185 /tests/components/tankerkoenig/ @guillempages @mib1185 /homeassistant/components/tapsaff/ @bazwilliams diff --git a/homeassistant/components/tami4/__init__.py b/homeassistant/components/tami4/__init__.py new file mode 100644 index 00000000000..846f1194930 --- /dev/null +++ b/homeassistant/components/tami4/__init__.py @@ -0,0 +1,46 @@ +"""The Tami4Edge integration.""" +from __future__ import annotations + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady + +from .const import API, CONF_REFRESH_TOKEN, COORDINATOR, DOMAIN +from .coordinator import Tami4EdgeWaterQualityCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tami4 from a config entry.""" + refresh_token = entry.data.get(CONF_REFRESH_TOKEN) + + try: + api = await hass.async_add_executor_job(Tami4EdgeAPI, refresh_token) + except exceptions.RefreshTokenExpiredException as ex: + raise ConfigEntryError("API Refresh token expired") from ex + except exceptions.TokenRefreshFailedException as ex: + raise ConfigEntryNotReady("Error connecting to API") from ex + + coordinator = Tami4EdgeWaterQualityCoordinator(hass, api) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + API: api, + COORDINATOR: coordinator, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/tami4/config_flow.py b/homeassistant/components/tami4/config_flow.py new file mode 100644 index 00000000000..b36ba9c46c0 --- /dev/null +++ b/homeassistant/components/tami4/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for edge integration.""" +from __future__ import annotations + +import logging +import re +from typing import Any + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv + +from .const import CONF_PHONE, CONF_REFRESH_TOKEN, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_STEP_PHONE_NUMBER_SCHEMA = vol.Schema({vol.Required(CONF_PHONE): cv.string}) + +_STEP_OTP_CODE_SCHEMA = vol.Schema({vol.Required("otp"): cv.string}) +_PHONE_MATCHER = re.compile(r"^(\+?972)?0?(?P\d{8,9})$") + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Tami4Edge.""" + + VERSION = 1 + + phone: str + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the otp request step.""" + errors = {} + if user_input is not None: + phone = user_input[CONF_PHONE].strip() + + try: + if m := _PHONE_MATCHER.match(phone): + self.phone = f"+972{m.group('number')}" + else: + raise InvalidPhoneNumber + await self.hass.async_add_executor_job( + Tami4EdgeAPI.request_otp, self.phone + ) + except InvalidPhoneNumber: + errors["base"] = "invalid_phone" + except exceptions.Tami4EdgeAPIException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return await self.async_step_otp() + + return self.async_show_form( + step_id="user", data_schema=_STEP_PHONE_NUMBER_SCHEMA, errors=errors + ) + + async def async_step_otp( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the otp submission step.""" + errors = {} + if user_input is not None: + otp = user_input["otp"] + try: + refresh_token = await self.hass.async_add_executor_job( + Tami4EdgeAPI.submit_otp, self.phone, otp + ) + api = await self.hass.async_add_executor_job( + Tami4EdgeAPI, refresh_token + ) + except exceptions.OTPFailedException: + errors["base"] = "invalid_auth" + except exceptions.Tami4EdgeAPIException: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title=api.device.name, data={CONF_REFRESH_TOKEN: refresh_token} + ) + + return self.async_show_form( + step_id="otp", data_schema=_STEP_OTP_CODE_SCHEMA, errors=errors + ) + + +class InvalidPhoneNumber(HomeAssistantError): + """Error to indicate that the phone number is invalid.""" diff --git a/homeassistant/components/tami4/const.py b/homeassistant/components/tami4/const.py new file mode 100644 index 00000000000..4e64bdf896d --- /dev/null +++ b/homeassistant/components/tami4/const.py @@ -0,0 +1,6 @@ +"""Constants for tami4 component.""" +DOMAIN = "tami4" +CONF_PHONE = "phone" +CONF_REFRESH_TOKEN = "refresh_token" +API = "api" +COORDINATOR = "coordinator" diff --git a/homeassistant/components/tami4/coordinator.py b/homeassistant/components/tami4/coordinator.py new file mode 100644 index 00000000000..ef57af71012 --- /dev/null +++ b/homeassistant/components/tami4/coordinator.py @@ -0,0 +1,61 @@ +"""Water quality coordinator for Tami4Edge.""" +from dataclasses import dataclass +from datetime import date, timedelta +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI, exceptions +from Tami4EdgeAPI.water_quality import WaterQuality + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class FlattenedWaterQuality: + """Flattened WaterQuality dataclass.""" + + uv_last_replacement: date + uv_upcoming_replacement: date + uv_status: str + filter_last_replacement: date + filter_upcoming_replacement: date + filter_status: str + filter_litters_passed: float + + def __init__(self, water_quality: WaterQuality) -> None: + """Flatten WaterQuality dataclass.""" + + self.uv_last_replacement = water_quality.uv.last_replacement + self.uv_upcoming_replacement = water_quality.uv.upcoming_replacement + self.uv_status = water_quality.uv.status + self.filter_last_replacement = water_quality.filter.last_replacement + self.filter_upcoming_replacement = water_quality.filter.upcoming_replacement + self.filter_status = water_quality.filter.status + self.filter_litters_passed = water_quality.filter.milli_litters_passed / 1000 + + +class Tami4EdgeWaterQualityCoordinator(DataUpdateCoordinator[FlattenedWaterQuality]): + """Tami4Edge water quality coordinator.""" + + def __init__(self, hass: HomeAssistant, api: Tami4EdgeAPI) -> None: + """Initialize the water quality coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Tami4Edge water quality coordinator", + update_interval=timedelta(minutes=60), + ) + self._api = api + + async def _async_update_data(self) -> FlattenedWaterQuality: + """Fetch data from the API endpoint.""" + try: + water_quality = await self.hass.async_add_executor_job( + self._api.get_water_quality + ) + + return FlattenedWaterQuality(water_quality) + except exceptions.APIRequestFailedException as ex: + raise UpdateFailed("Error communicating with API") from ex diff --git a/homeassistant/components/tami4/entity.py b/homeassistant/components/tami4/entity.py new file mode 100644 index 00000000000..50c066b9b6d --- /dev/null +++ b/homeassistant/components/tami4/entity.py @@ -0,0 +1,33 @@ +"""Base entity for Tami4Edge.""" +from __future__ import annotations + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .const import DOMAIN + + +class Tami4EdgeBaseEntity(Entity): + """Base class for Tami4Edge entities.""" + + _attr_has_entity_name = True + + def __init__( + self, api: Tami4EdgeAPI, entity_description: EntityDescription + ) -> None: + """Initialize the Tami4Edge.""" + self._state = None + self._api = api + device_id = api.device.psn + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + manufacturer="Stratuss", + name=api.device.name, + model="Tami4", + sw_version=api.device.device_firmware, + suggested_area="Kitchen", + ) diff --git a/homeassistant/components/tami4/manifest.json b/homeassistant/components/tami4/manifest.json new file mode 100644 index 00000000000..49cbf6fe1c6 --- /dev/null +++ b/homeassistant/components/tami4/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "tami4", + "name": "Tami4 Edge / Edge+", + "codeowners": ["@Guy293"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/tami4", + "iot_class": "cloud_polling", + "requirements": ["Tami4EdgeAPI==2.1"] +} diff --git a/homeassistant/components/tami4/sensor.py b/homeassistant/components/tami4/sensor.py new file mode 100644 index 00000000000..df271da7309 --- /dev/null +++ b/homeassistant/components/tami4/sensor.py @@ -0,0 +1,118 @@ +"""Sensor entities for Tami4Edge.""" +import logging + +from Tami4EdgeAPI import Tami4EdgeAPI + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfVolume +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import API, COORDINATOR, DOMAIN +from .coordinator import Tami4EdgeWaterQualityCoordinator +from .entity import Tami4EdgeBaseEntity + +_LOGGER = logging.getLogger(__name__) + +ENTITY_DESCRIPTIONS = [ + SensorEntityDescription( + key="uv_last_replacement", + translation_key="uv_last_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="uv_upcoming_replacement", + translation_key="uv_upcoming_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="uv_status", + translation_key="uv_status", + icon="mdi:clipboard-check-multiple", + ), + SensorEntityDescription( + key="filter_last_replacement", + translation_key="filter_last_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="filter_upcoming_replacement", + translation_key="filter_upcoming_replacement", + icon="mdi:calendar", + device_class=SensorDeviceClass.DATE, + ), + SensorEntityDescription( + key="filter_status", + translation_key="filter_status", + icon="mdi:clipboard-check-multiple", + ), + SensorEntityDescription( + key="filter_litters_passed", + translation_key="filter_litters_passed", + icon="mdi:water", + state_class=SensorStateClass.TOTAL, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Perform the setup for Tami4Edge.""" + data = hass.data[DOMAIN][entry.entry_id] + api: Tami4EdgeAPI = data[API] + coordinator: Tami4EdgeWaterQualityCoordinator = data[COORDINATOR] + + entities = [] + for entity_description in ENTITY_DESCRIPTIONS: + entities.append( + Tami4EdgeSensorEntity( + coordinator=coordinator, + api=api, + entity_description=entity_description, + ) + ) + + async_add_entities(entities) + + +class Tami4EdgeSensorEntity( + Tami4EdgeBaseEntity, + CoordinatorEntity[Tami4EdgeWaterQualityCoordinator], + SensorEntity, +): + """Representation of the entity.""" + + def __init__( + self, + coordinator: Tami4EdgeWaterQualityCoordinator, + api: Tami4EdgeAPI, + entity_description: SensorEntityDescription, + ) -> None: + """Initialize the Tami4Edge sensor entity.""" + Tami4EdgeBaseEntity.__init__(self, api, entity_description) + CoordinatorEntity.__init__(self, coordinator) + self._update_attr() + + def _update_attr(self) -> None: + self._attr_native_value = getattr( + self.coordinator.data, self.entity_description.key + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_attr() + self.async_write_ha_state() diff --git a/homeassistant/components/tami4/strings.json b/homeassistant/components/tami4/strings.json new file mode 100644 index 00000000000..9036d92d6f1 --- /dev/null +++ b/homeassistant/components/tami4/strings.json @@ -0,0 +1,54 @@ +{ + "entity": { + "sensor": { + "uv_last_replacement": { + "name": "UV last replacement" + }, + "uv_upcoming_replacement": { + "name": "UV upcoming replacement" + }, + "uv_status": { + "name": "UV status" + }, + "filter_last_replacement": { + "name": "Filter last replacement" + }, + "filter_upcoming_replacement": { + "name": "Filter upcoming replacement" + }, + "filter_status": { + "name": "Filter status" + }, + "filter_litters_passed": { + "name": "Filter water passed" + } + } + }, + "config": { + "step": { + "user": { + "title": "SMS Verification", + "description": "Enter your phone number (same as what you used to register to the tami4 app)", + "data": { + "phone": "Phone Number" + } + }, + "otp": { + "title": "[%key:component::tami4::config::step::user::title%]", + "description": "Enter the code you received via SMS", + "data": { + "otp": "SMS Code" + } + } + }, + "error": { + "invalid_phone": "Invalid phone number, please use the following format: +972xxxxxxxx", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 25d8a6f0d73..fa83d93c87b 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -470,6 +470,7 @@ FLOWS = { "system_bridge", "tado", "tailscale", + "tami4", "tankerkoenig", "tasmota", "tautulli", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9c53d998bcd..bb36eaaad1f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5629,6 +5629,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "tami4": { + "name": "Tami4 Edge / Edge+", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "tank_utility": { "name": "Tank Utility", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 435a9f5f2ff..0bc95fa5970 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3042,6 +3042,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.tami4.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.tautulli.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 53dd9407863..639c79ae937 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -134,6 +134,9 @@ RtmAPI==0.7.2 # homeassistant.components.sql SQLAlchemy==2.0.21 +# homeassistant.components.tami4 +Tami4EdgeAPI==2.1 + # homeassistant.components.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 983c2fa0a5f..b037dbdba16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -121,6 +121,9 @@ RtmAPI==0.7.2 # homeassistant.components.sql SQLAlchemy==2.0.21 +# homeassistant.components.tami4 +Tami4EdgeAPI==2.1 + # homeassistant.components.onvif WSDiscovery==2.0.0 diff --git a/tests/components/tami4/__init__.py b/tests/components/tami4/__init__.py new file mode 100644 index 00000000000..2ffef84827e --- /dev/null +++ b/tests/components/tami4/__init__.py @@ -0,0 +1 @@ +"""Tests for the Tami4 integration.""" diff --git a/tests/components/tami4/conftest.py b/tests/components/tami4/conftest.py new file mode 100644 index 00000000000..2e8b4f4ffac --- /dev/null +++ b/tests/components/tami4/conftest.py @@ -0,0 +1,125 @@ +"""Common fixutres with default mocks as well as common test helper methods.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest +from Tami4EdgeAPI.device import Device +from Tami4EdgeAPI.water_quality import UV, Filter, WaterQuality + +from homeassistant.components.tami4.const import CONF_REFRESH_TOKEN, DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def create_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create an entry in hass.""" + + entry = MockConfigEntry( + domain=DOMAIN, + title="Device name", + data={CONF_REFRESH_TOKEN: "refresh_token"}, + ) + + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +@pytest.fixture +def mock_api(mock__get_devices, mock_get_water_quality): + """Fixture to mock all API calls.""" + + +@pytest.fixture +def mock__get_devices(request): + """Fixture to mock _get_devices which makes a call to the API.""" + + side_effect = getattr(request, "param", None) + + device = Device( + id=1, + name="Drink Water", + connected=True, + psn="psn", + type="type", + device_firmware="v1.1", + ) + + with patch( + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI._get_devices", + return_value=[device], + side_effect=side_effect, + ): + yield + + +@pytest.fixture +def mock_get_water_quality(request): + """Fixture to mock get_water_quality which makes a call to the API.""" + + side_effect = getattr(request, "param", None) + + water_quality = WaterQuality( + uv=UV( + last_replacement=int(datetime.now().timestamp()), + upcoming_replacement=int(datetime.now().timestamp()), + status="on", + ), + filter=Filter( + last_replacement=int(datetime.now().timestamp()), + upcoming_replacement=int(datetime.now().timestamp()), + status="on", + milli_litters_passed=1000, + ), + ) + + with patch( + "Tami4EdgeAPI.Tami4EdgeAPI.Tami4EdgeAPI.get_water_quality", + return_value=water_quality, + side_effect=side_effect, + ): + yield + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + + with patch( + "homeassistant.components.tami4.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_request_otp(request): + """Mock request_otp.""" + + side_effect = getattr(request, "param", None) + + with patch( + "homeassistant.components.tami4.config_flow.Tami4EdgeAPI.request_otp", + return_value=None, + side_effect=side_effect, + ) as mock_request_otp: + yield mock_request_otp + + +@pytest.fixture +def mock_submit_otp(request): + """Mock submit_otp.""" + + side_effect = getattr(request, "param", None) + + with patch( + "homeassistant.components.tami4.config_flow.Tami4EdgeAPI.submit_otp", + return_value="refresh_token", + side_effect=side_effect, + ) as mock_submit_otp: + yield mock_submit_otp diff --git a/tests/components/tami4/test_config_flow.py b/tests/components/tami4/test_config_flow.py new file mode 100644 index 00000000000..341e56bec84 --- /dev/null +++ b/tests/components/tami4/test_config_flow.py @@ -0,0 +1,163 @@ +"""Tests for the Tami4 config flow.""" + +import pytest +from Tami4EdgeAPI import exceptions + +from homeassistant import config_entries +from homeassistant.components.tami4.const import CONF_PHONE, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_step_user_valid_number( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + +async def test_step_user_invalid_number( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, +) -> None: + """Test user step with invalid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+275123"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_phone"} + + +@pytest.mark.parametrize( + ("mock_request_otp", "expected_error"), + [(Exception, "unknown"), (exceptions.OTPFailedException, "cannot_connect")], + indirect=["mock_request_otp"], +) +async def test_step_user_exception( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock__get_devices, + expected_error, +) -> None: + """Test user step with exception.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": expected_error} + + +async def test_step_otp_valid( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock_submit_otp, + mock__get_devices, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"otp": "123456"}, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Drink Water" + assert "refresh_token" in result["data"] + + +@pytest.mark.parametrize( + ("mock_submit_otp", "expected_error"), + [ + (Exception, "unknown"), + (exceptions.Tami4EdgeAPIException, "cannot_connect"), + (exceptions.OTPFailedException, "invalid_auth"), + ], + indirect=["mock_submit_otp"], +) +async def test_step_otp_exception( + hass: HomeAssistant, + mock_setup_entry, + mock_request_otp, + mock_submit_otp, + mock__get_devices, + expected_error, +) -> None: + """Test user step with valid phone number.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_PHONE: "+972555555555"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"otp": "123456"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "otp" + assert result["errors"] == {"base": expected_error} diff --git a/tests/components/tami4/test_init.py b/tests/components/tami4/test_init.py new file mode 100644 index 00000000000..ad3f50a377e --- /dev/null +++ b/tests/components/tami4/test_init.py @@ -0,0 +1,59 @@ +"""Test the Tami4 component.""" +import pytest +from Tami4EdgeAPI import exceptions + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import create_config_entry + + +async def test_init_success(mock_api, hass: HomeAssistant) -> None: + """Test setup and that we can create the entry.""" + + entry = await create_config_entry(hass) + assert entry.state == ConfigEntryState.LOADED + + +@pytest.mark.parametrize( + "mock_get_water_quality", [exceptions.APIRequestFailedException], indirect=True +) +async def test_init_with_api_error(mock_api, hass: HomeAssistant) -> None: + """Test init with api error.""" + + entry = await create_config_entry(hass) + assert entry.state == ConfigEntryState.SETUP_RETRY + + +@pytest.mark.parametrize( + ("mock__get_devices", "expected_state"), + [ + ( + exceptions.RefreshTokenExpiredException, + ConfigEntryState.SETUP_ERROR, + ), + ( + exceptions.TokenRefreshFailedException, + ConfigEntryState.SETUP_RETRY, + ), + ], + indirect=["mock__get_devices"], +) +async def test_init_error_raised( + mock_api, hass: HomeAssistant, expected_state: ConfigEntryState +) -> None: + """Test init when an error is raised.""" + + entry = await create_config_entry(hass) + assert entry.state == expected_state + + +async def test_load_unload(mock_api, hass: HomeAssistant) -> None: + """Config entry can be unloaded.""" + + entry = await create_config_entry(hass) + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED From 6450ae8d281ad0cbb022d7ecc35736a68e812188 Mon Sep 17 00:00:00 2001 From: Betacart Date: Thu, 12 Oct 2023 13:18:43 +0200 Subject: [PATCH 388/968] Fix typo in remember the milk strings (#101869) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/remember_the_milk/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/remember_the_milk/strings.json b/homeassistant/components/remember_the_milk/strings.json index 5590691e245..da499b0584c 100644 --- a/homeassistant/components/remember_the_milk/strings.json +++ b/homeassistant/components/remember_the_milk/strings.json @@ -16,7 +16,7 @@ }, "complete_task": { "name": "Complete task", - "description": "Completes a tasks that was privously created.", + "description": "Completes a task that was previously created.", "fields": { "id": { "name": "ID", From 3e4edc8eddf96734eb5edeacdff30acbb7ee25f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 12 Oct 2023 13:42:00 +0200 Subject: [PATCH 389/968] Move Withings entity descriptions to platforms (#101820) --- .../components/withings/binary_sensor.py | 41 ++----- homeassistant/components/withings/entity.py | 26 +--- homeassistant/components/withings/sensor.py | 23 +++- tests/components/withings/test_sensor.py | 114 ++---------------- 4 files changed, 40 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 309ef45623f..629114247ce 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -1,42 +1,17 @@ """Sensors flow for Withings.""" from __future__ import annotations -from dataclasses import dataclass - -from withings_api.common import NotifyAppli - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, - BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, Measurement +from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator -from .entity import WithingsEntity, WithingsEntityDescription - - -@dataclass -class WithingsBinarySensorEntityDescription( - BinarySensorEntityDescription, WithingsEntityDescription -): - """Immutable class for describing withings binary sensor data.""" - - -BINARY_SENSORS = [ - # Webhook measurements. - WithingsBinarySensorEntityDescription( - key=Measurement.IN_BED.value, - measurement=Measurement.IN_BED, - measure_type=NotifyAppli.BED_IN, - translation_key="in_bed", - icon="mdi:bed", - device_class=BinarySensorDeviceClass.OCCUPANCY, - ), -] +from .entity import WithingsEntity async def async_setup_entry( @@ -47,9 +22,7 @@ async def async_setup_entry( """Set up the sensor config entry.""" coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - entities = [ - WithingsBinarySensor(coordinator, attribute) for attribute in BINARY_SENSORS - ] + entities = [WithingsBinarySensor(coordinator)] async_add_entities(entities) @@ -57,7 +30,13 @@ async def async_setup_entry( class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): """Implementation of a Withings sensor.""" - entity_description: WithingsBinarySensorEntityDescription + _attr_icon = "mdi:bed" + _attr_translation_key = "in_bed" + _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + + def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None: + """Initialize binary sensor.""" + super().__init__(coordinator, "in_bed") @property def is_on(self) -> bool | None: diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 8005f97bfaa..8d2c815b340 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -1,46 +1,26 @@ """Base entity for Withings.""" from __future__ import annotations -from dataclasses import dataclass - -from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli - from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, Measurement +from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator -@dataclass -class WithingsEntityDescriptionMixin: - """Mixin for describing withings data.""" - - measurement: Measurement - measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - - -@dataclass -class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): - """Immutable class for describing withings data.""" - - class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): """Base class for withings entities.""" - entity_description: WithingsEntityDescription _attr_has_entity_name = True def __init__( self, coordinator: WithingsDataUpdateCoordinator, - description: WithingsEntityDescription, + key: str, ) -> None: """Initialize the Withings entity.""" super().__init__(coordinator) - self.entity_description = description - self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{description.measurement.value}" + self._attr_unique_id = f"withings_{coordinator.config_entry.unique_id}_{key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(coordinator.config_entry.unique_id))}, manufacturer="Withings", diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 77a706dc55d..bb615dfb7ca 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -33,14 +33,22 @@ from .const import ( Measurement, ) from .coordinator import WithingsDataUpdateCoordinator -from .entity import WithingsEntity, WithingsEntityDescription +from .entity import WithingsEntity + + +@dataclass +class WithingsEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement: Measurement + measure_type: GetSleepSummaryField | MeasureType @dataclass class WithingsSensorEntityDescription( - SensorEntityDescription, WithingsEntityDescription + SensorEntityDescription, WithingsEntityDescriptionMixin ): - """Immutable class for describing withings binary sensor data.""" + """Immutable class for describing withings data.""" SENSORS = [ @@ -371,6 +379,15 @@ class WithingsSensor(WithingsEntity, SensorEntity): entity_description: WithingsSensorEntityDescription + def __init__( + self, + coordinator: WithingsDataUpdateCoordinator, + entity_description: WithingsSensorEntityDescription, + ) -> None: + """Initialize sensor.""" + super().__init__(coordinator, entity_description.key) + self.entity_description = entity_description + @property def native_value(self) -> None | str | int | float: """Return the state of the entity.""" diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 44ae10b6a94..febf0a1a5d9 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,137 +1,37 @@ """Tests for the Withings component.""" from datetime import timedelta -from typing import Any from unittest.mock import AsyncMock from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import DOMAIN, Measurement -from homeassistant.components.withings.entity import WithingsEntityDescription +from homeassistant.components.withings.const import DOMAIN from homeassistant.components.withings.sensor import SENSORS from homeassistant.const import STATE_UNAVAILABLE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry -from . import call_webhook, prepare_webhook_setup, setup_integration -from .conftest import USER_ID, WEBHOOK_ID +from . import setup_integration +from .conftest import USER_ID from tests.common import MockConfigEntry, async_fire_time_changed -from tests.typing import ClientSessionGenerator - -WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { - attr.measurement: attr for attr in SENSORS -} - - -EXPECTED_DATA = ( - (Measurement.WEIGHT_KG, 70.0), - (Measurement.FAT_MASS_KG, 5.0), - (Measurement.FAT_FREE_MASS_KG, 60.0), - (Measurement.MUSCLE_MASS_KG, 50.0), - (Measurement.BONE_MASS_KG, 10.0), - (Measurement.HEIGHT_M, 2.0), - (Measurement.FAT_RATIO_PCT, 0.07), - (Measurement.DIASTOLIC_MMHG, 70.0), - (Measurement.SYSTOLIC_MMGH, 100.0), - (Measurement.HEART_PULSE_BPM, 60.0), - (Measurement.SPO2_PCT, 0.95), - (Measurement.HYDRATION, 0.95), - (Measurement.PWV, 100.0), - (Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), - (Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), - (Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), - (Measurement.SLEEP_HEART_RATE_MAX, 165.0), - (Measurement.SLEEP_HEART_RATE_MIN, 166.0), - (Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), - (Measurement.SLEEP_REM_DURATION_SECONDS, 336), - (Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), - (Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), - (Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), - (Measurement.SLEEP_SCORE, 222), - (Measurement.SLEEP_SNORING, 173.0), - (Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), - (Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), - (Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), - (Measurement.SLEEP_WAKEUP_COUNT, 350), - (Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), -) async def async_get_entity_id( hass: HomeAssistant, - description: WithingsEntityDescription, + key: str, user_id: int, platform: str, ) -> str | None: """Get an entity id for a user's attribute.""" entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{description.measurement.value}" + unique_id = f"withings_{user_id}_{key}" return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) -def async_assert_state_equals( - entity_id: str, - state_obj: State, - expected: Any, - description: WithingsEntityDescription, -) -> None: - """Assert at given state matches what is expected.""" - assert state_obj, f"Expected entity {entity_id} to exist but it did not" - - assert state_obj.state == str(expected), ( - f"Expected {expected} but was {state_obj.state} " - f"for measure {description.measurement}, {entity_id}" - ) - - -@pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_sensor_default_enabled_entities( - hass: HomeAssistant, - withings: AsyncMock, - webhook_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, - freezer: FrozenDateTimeFactory, -) -> None: - """Test entities enabled by default.""" - await setup_integration(hass, webhook_config_entry) - await prepare_webhook_setup(hass, freezer) - entity_registry: EntityRegistry = er.async_get(hass) - - client = await hass_client_no_auth() - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, - client, - ) - assert resp.message_code == 0 - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, - client, - ) - assert resp.message_code == 0 - - for measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id(hass, attribute, USER_ID, SENSOR_DOMAIN) - state_obj = hass.states.get(entity_id) - - async_assert_state_equals(entity_id, state_obj, expected, attribute) - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, @@ -143,7 +43,7 @@ async def test_all_entities( await setup_integration(hass, polling_config_entry) for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) + entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot From 6e1c23906cf747ad0c1ca5bbec5379c8bac0591e Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 12 Oct 2023 14:54:16 +0200 Subject: [PATCH 390/968] Add base entity class in vicare integration (#101870) * add entity base class * depend on base class * add device info to base class * remove individual DeviceInfo * move class * fix imports * add file comment * use super() * fix formatting * add vicare/entity.py --- .coveragerc | 1 + .../components/vicare/binary_sensor.py | 13 +++--------- homeassistant/components/vicare/button.py | 12 +++-------- homeassistant/components/vicare/climate.py | 12 +++-------- homeassistant/components/vicare/entity.py | 20 +++++++++++++++++++ homeassistant/components/vicare/sensor.py | 16 +++------------ .../components/vicare/water_heater.py | 12 +++-------- 7 files changed, 36 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/vicare/entity.py diff --git a/.coveragerc b/.coveragerc index a13959d0185..3e8de435832 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1470,6 +1470,7 @@ omit = homeassistant/components/vicare/binary_sensor.py homeassistant/components/vicare/button.py homeassistant/components/vicare/climate.py + homeassistant/components/vicare/entity.py homeassistant/components/vicare/sensor.py homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 5aa76dc9962..ad8727a12ef 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -19,11 +19,11 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -182,7 +182,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareBinarySensor(BinarySensorEntity): +class ViCareBinarySensor(ViCareEntity, BinarySensorEntity): """Representation of a ViCare sensor.""" entity_description: ViCareBinarySensorEntityDescription @@ -191,18 +191,11 @@ class ViCareBinarySensor(BinarySensorEntity): self, name, api, device_config, description: ViCareBinarySensorEntityDescription ) -> None: """Initialize the sensor.""" + super().__init__(device_config) self.entity_description = description self._attr_name = name self._api = api - self.entity_description = description self._device_config = device_config - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) @property def available(self): diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 7fd8cccd3a4..e855b274466 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -16,11 +16,11 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixinWithSet from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -92,7 +92,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareButton(ButtonEntity): +class ViCareButton(ViCareEntity, ButtonEntity): """Representation of a ViCare button.""" entity_description: ViCareButtonEntityDescription @@ -101,16 +101,10 @@ class ViCareButton(ButtonEntity): self, name, api, device_config, description: ViCareButtonEntityDescription ) -> None: """Initialize the button.""" + super().__init__(device_config) self.entity_description = description self._device_config = device_config self._api = api - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def press(self) -> None: """Handle the button press.""" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index a9188adc964..3a2dcafac2d 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -33,10 +33,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -134,7 +134,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareClimate(ClimateEntity): +class ViCareClimate(ViCareEntity, ClimateEntity): """Representation of the ViCare heating climate device.""" _attr_precision = PRECISION_TENTHS @@ -149,6 +149,7 @@ class ViCareClimate(ClimateEntity): def __init__(self, name, api, circuit, device_config): """Initialize the climate device.""" + super().__init__(device_config) self._attr_name = name self._api = api self._circuit = circuit @@ -157,13 +158,6 @@ class ViCareClimate(ClimateEntity): self._current_program = None self._current_action = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py new file mode 100644 index 00000000000..90779bb26d1 --- /dev/null +++ b/homeassistant/components/vicare/entity.py @@ -0,0 +1,20 @@ +"""Entities for the ViCare integration.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN + + +class ViCareEntity(Entity): + """Base class for ViCare entities.""" + + def __init__(self, device_config) -> None: + """Initialize the entity.""" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), + manufacturer="Viessmann", + model=device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index d7ac7f25274..6d0fd2423c4 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -30,7 +30,6 @@ from homeassistant.const import ( UnitOfVolume, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -43,6 +42,7 @@ from .const import ( VICARE_NAME, VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -660,7 +660,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareSensor(SensorEntity): +class ViCareSensor(ViCareEntity, SensorEntity): """Representation of a ViCare sensor.""" entity_description: ViCareSensorEntityDescription @@ -669,22 +669,12 @@ class ViCareSensor(SensorEntity): self, name, api, device_config, description: ViCareSensorEntityDescription ) -> None: """Initialize the sensor.""" + super().__init__(device_config) self.entity_description = description self._attr_name = name self._api = api self._device_config = device_config - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_config.getConfig().serial)}, - name=self._device_config.getModel(), - manufacturer="Viessmann", - model=self._device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) - @property def available(self): """Return True if entity is available.""" diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 3357d2e0a31..7e90c57e320 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -17,10 +17,10 @@ from homeassistant.components.water_heater import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -90,7 +90,7 @@ async def async_setup_entry( async_add_entities(entities) -class ViCareWater(WaterHeaterEntity): +class ViCareWater(ViCareEntity, WaterHeaterEntity): """Representation of the ViCare domestic hot water device.""" _attr_precision = PRECISION_TENTHS @@ -102,19 +102,13 @@ class ViCareWater(WaterHeaterEntity): def __init__(self, name, api, circuit, device_config): """Initialize the DHW water_heater device.""" + super().__init__(device_config) self._attr_name = name self._api = api self._circuit = circuit self._attributes = {} self._current_mode = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, device_config.getConfig().serial)}, - name=device_config.getModel(), - manufacturer="Viessmann", - model=device_config.getModel(), - configuration_url="https://developer.viessmann.com/", - ) def update(self) -> None: """Let HA know there has been an update from the ViCare API.""" From 5c52a15df7b057e00630dc04ce170410099cfdca Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 12 Oct 2023 15:01:23 +0200 Subject: [PATCH 391/968] Fix type issue in vicare integration (#101872) * fix type issue * fix type issue * fix type issue * extract from constructor * Revert "extract from constructor" This reverts commit a33de7ca8e75845b81c548c73745b15a98d2bd63. --- homeassistant/components/vicare/climate.py | 4 ++-- homeassistant/components/vicare/water_heater.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 3a2dcafac2d..bca0b96c726 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -146,6 +146,7 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_max_temp = VICARE_TEMP_HEATING_MAX _attr_target_temperature_step = PRECISION_WHOLE _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) + _current_action: bool | None = None def __init__(self, name, api, circuit, device_config): """Initialize the climate device.""" @@ -153,10 +154,9 @@ class ViCareClimate(ViCareEntity, ClimateEntity): self._attr_name = name self._api = api self._circuit = circuit - self._attributes = {} + self._attributes: dict[str, Any] = {} self._current_mode = None self._current_program = None - self._current_action = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" def update(self) -> None: diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 7e90c57e320..2e312d54d7d 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -106,7 +106,7 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): self._attr_name = name self._api = api self._circuit = circuit - self._attributes = {} + self._attributes: dict[str, Any] = {} self._current_mode = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" From dc29190564484b3e65a941c2ce53be7b57baa843 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 16:44:30 +0200 Subject: [PATCH 392/968] CountrySelector (#100963) * CountrySelector * rename * remove multiple for now * Add no_sort * Update homeassistant/helpers/selector.py --------- Co-authored-by: Erik Montnemery --- homeassistant/helpers/selector.py | 35 +++++++++++++++++++++++++++++++ tests/helpers/test_selector.py | 20 ++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index efb1ee0b1f1..1dba926a9af 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id +from homeassistant.generated.countries import COUNTRIES from homeassistant.util import decorator from homeassistant.util.yaml import dumper @@ -564,6 +565,40 @@ class ConversationAgentSelector(Selector[ConversationAgentSelectorConfig]): return agent +class CountrySelectorConfig(TypedDict, total=False): + """Class to represent a country selector config.""" + + countries: list[str] + no_sort: bool + + +@SELECTORS.register("country") +class CountrySelector(Selector[CountrySelectorConfig]): + """Selector for a single-choice country select.""" + + selector_type = "country" + + CONFIG_SCHEMA = vol.Schema( + { + vol.Optional("countries"): [str], + vol.Optional("no_sort", default=False): cv.boolean, + } + ) + + def __init__(self, config: CountrySelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> Any: + """Validate the passed selection.""" + country: str = vol.Schema(str)(data) + if "countries" in self.config and ( + country not in self.config["countries"] or country not in COUNTRIES + ): + raise vol.Invalid(f"Value {country} is not a valid option") + return country + + class DateSelectorConfig(TypedDict): """Class to represent a date selector config.""" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 590526cdb2b..ee4749be346 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -479,6 +479,26 @@ def test_config_entry_selector_schema( _test_selector("config_entry", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + ( + ( + {}, + ("NL", "DE"), + (None, True, 1), + ), + ( + {"countries": ["NL", "DE"]}, + ("NL", "DE"), + (None, True, 1, "sv", "en"), + ), + ), +) +def test_country_selector_schema(schema, valid_selections, invalid_selections) -> None: + """Test country selector.""" + _test_selector("country", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), (({}, ("00:00:00",), ("blah", None)),), From 8870991802c754dabae43d328beca029fdf06788 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 17:07:11 +0200 Subject: [PATCH 393/968] Remove codeowner Trafikverket (#101881) --- CODEOWNERS | 8 ++++---- homeassistant/components/trafikverket_train/manifest.json | 2 +- .../components/trafikverket_weatherstation/manifest.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d35d1d964fb..33f5fb766b4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1323,10 +1323,10 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_camera/ @gjohansson-ST /homeassistant/components/trafikverket_ferry/ @gjohansson-ST /tests/components/trafikverket_ferry/ @gjohansson-ST -/homeassistant/components/trafikverket_train/ @endor-force @gjohansson-ST -/tests/components/trafikverket_train/ @endor-force @gjohansson-ST -/homeassistant/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST -/tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST +/homeassistant/components/trafikverket_train/ @gjohansson-ST +/tests/components/trafikverket_train/ @gjohansson-ST +/homeassistant/components/trafikverket_weatherstation/ @gjohansson-ST +/tests/components/trafikverket_weatherstation/ @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins /homeassistant/components/trend/ @jpbede diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index f81c2e0bf76..b1dd39c5156 100644 --- a/homeassistant/components/trafikverket_train/manifest.json +++ b/homeassistant/components/trafikverket_train/manifest.json @@ -1,7 +1,7 @@ { "domain": "trafikverket_train", "name": "Trafikverket Train", - "codeowners": ["@endor-force", "@gjohansson-ST"], + "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_train", "iot_class": "cloud_polling", diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index cb16cd62d36..d9b4f20eeb7 100644 --- a/homeassistant/components/trafikverket_weatherstation/manifest.json +++ b/homeassistant/components/trafikverket_weatherstation/manifest.json @@ -1,7 +1,7 @@ { "domain": "trafikverket_weatherstation", "name": "Trafikverket Weather Station", - "codeowners": ["@endor-force", "@gjohansson-ST"], + "codeowners": ["@gjohansson-ST"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation", "iot_class": "cloud_polling", From e5f37050a949f4addabf08e268c082723d717da8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 17:38:48 +0200 Subject: [PATCH 394/968] Use CountrySelector in Buienradar (#101882) --- homeassistant/components/buienradar/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/buienradar/config_flow.py b/homeassistant/components/buienradar/config_flow.py index 87810edda2e..4a81a774b4f 100644 --- a/homeassistant/components/buienradar/config_flow.py +++ b/homeassistant/components/buienradar/config_flow.py @@ -32,8 +32,8 @@ from .const import ( OPTIONS_SCHEMA = vol.Schema( { - vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): vol.In( - SUPPORTED_COUNTRY_CODES + vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY): selector.CountrySelector( + selector.CountrySelectorConfig(countries=SUPPORTED_COUNTRY_CODES) ), vol.Optional(CONF_DELTA, default=DEFAULT_DELTA): selector.NumberSelector( selector.NumberSelectorConfig( From b6d8211c6cf00734fee99bb06da2979b7ebdccce Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 17:39:08 +0200 Subject: [PATCH 395/968] Use CountrySelector in Workday (#101879) --- homeassistant/components/workday/config_flow.py | 10 +++++----- homeassistant/components/workday/strings.json | 5 ----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index 3a4e381792e..907f5c5bdb5 100644 --- a/homeassistant/components/workday/config_flow.py +++ b/homeassistant/components/workday/config_flow.py @@ -16,6 +16,8 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import ( + CountrySelector, + CountrySelectorConfig, NumberSelector, NumberSelectorConfig, NumberSelectorMode, @@ -113,11 +115,9 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: DATA_SCHEMA_SETUP = vol.Schema( { vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), - vol.Optional(CONF_COUNTRY): SelectSelector( - SelectSelectorConfig( - options=list(list_supported_countries()), - mode=SelectSelectorMode.DROPDOWN, - translation_key=CONF_COUNTRY, + vol.Optional(CONF_COUNTRY): CountrySelector( + CountrySelectorConfig( + countries=list(list_supported_countries()), ) ), } diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 0249b580b60..d0ffecd0f7e 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -69,11 +69,6 @@ } }, "selector": { - "country": { - "options": { - "none": "No country" - } - }, "province": { "options": { "none": "No subdivision" From a4e0b3140bcfb35455a37d20f6fb8da795049cf8 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 18:05:51 +0200 Subject: [PATCH 396/968] Add missing fan mode in Sensibo (#101883) * Add missing fan mode in Sensibo * translations --- homeassistant/components/sensibo/climate.py | 1 + homeassistant/components/sensibo/strings.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index f8ecd1b9b80..40aa54e5d56 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -57,6 +57,7 @@ BOOST_INCLUSIVE = "boost_inclusive" AVAILABLE_FAN_MODES = { "quiet", "low", + "medium_low", "medium", "medium_high", "high", diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index ddd164225fc..9af6139b789 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -125,6 +125,7 @@ "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium_low": "Medium low", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "Medium high", "strong": "Strong", @@ -210,6 +211,7 @@ "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]", "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", @@ -351,6 +353,7 @@ "quiet": "Quiet", "strong": "Strong", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", + "medium_low": "Medium low", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", "medium_high": "Medium high", "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", From edf510f9c0fd6ed2253aa0afd70728c75e4668cf Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 12 Oct 2023 18:19:11 +0200 Subject: [PATCH 397/968] Bump pymodbus v3.5.4 (#101877) --- homeassistant/components/modbus/manifest.json | 2 +- homeassistant/components/modbus/modbus.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 7faf873b655..93a3f22c97d 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_polling", "loggers": ["pymodbus"], "quality_scale": "gold", - "requirements": ["pymodbus==3.5.2"] + "requirements": ["pymodbus==3.5.4"] } diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 4ef205aace3..764cf4930f7 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -443,7 +443,7 @@ class ModbusHub: if not hasattr(result, entry.attr): self._log_error(str(result)) return None - if result.isError(): # type: ignore[no-untyped-call] + if result.isError(): self._log_error("Error: pymodbus returned isError True") return None self._in_error = False diff --git a/requirements_all.txt b/requirements_all.txt index 639c79ae937..cbb67c2620b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1866,7 +1866,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.2 +pymodbus==3.5.4 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b037dbdba16..fb34a2d466a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1403,7 +1403,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.2 +pymodbus==3.5.4 # homeassistant.components.monoprice pymonoprice==0.4 From 3843e91af0714b7203e10ac4b37f86bd0b1359b2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 12 Oct 2023 19:13:21 +0200 Subject: [PATCH 398/968] Use device class translation for Sensibo update entity (#101888) Use device class translation for Sensibo update --- homeassistant/components/sensibo/strings.json | 5 ----- homeassistant/components/sensibo/update.py | 1 - tests/components/sensibo/test_update.py | 8 ++++---- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 9af6139b789..20d41840725 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -340,11 +340,6 @@ "name": "Pure Boost" } }, - "update": { - "fw_ver_available": { - "name": "Update available" - } - }, "climate": { "climate_device": { "state_attributes": { diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 46b9b860ca6..62e8bbff3ae 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -41,7 +41,6 @@ class SensiboDeviceUpdateEntityDescription( DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( SensiboDeviceUpdateEntityDescription( key="fw_ver_available", - translation_key="fw_ver_available", device_class=UpdateDeviceClass.FIRMWARE, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:rocket-launch", diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index c65ee5995ee..72e9ae9f902 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -15,7 +15,7 @@ from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def test_select( +async def test_update( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, @@ -23,8 +23,8 @@ async def test_select( ) -> None: """Test the Sensibo update.""" - state1 = hass.states.get("update.hallway_update_available") - state2 = hass.states.get("update.kitchen_update_available") + state1 = hass.states.get("update.hallway_firmware") + state2 = hass.states.get("update.kitchen_firmware") assert state1.state == STATE_ON assert state1.attributes["installed_version"] == "SKY30046" assert state1.attributes["latest_version"] == "SKY30048" @@ -43,5 +43,5 @@ async def test_select( ) await hass.async_block_till_done() - state1 = hass.states.get("update.hallway_update_available") + state1 = hass.states.get("update.hallway_firmware") assert state1.state == STATE_OFF From 472ab437e8c47a321413a1fa8513f9a5842b4e7a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 19:16:45 +0200 Subject: [PATCH 399/968] Translations in Sensibo (#101887) --- homeassistant/components/sensibo/strings.json | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sensibo/strings.json b/homeassistant/components/sensibo/strings.json index 20d41840725..6081c668d89 100644 --- a/homeassistant/components/sensibo/strings.json +++ b/homeassistant/components/sensibo/strings.json @@ -345,12 +345,12 @@ "state_attributes": { "fan_mode": { "state": { - "quiet": "Quiet", - "strong": "Strong", + "quiet": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::quiet%]", + "strong": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::strong%]", "low": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::low%]", - "medium_low": "Medium low", + "medium_low": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_low%]", "medium": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::medium%]", - "medium_high": "Medium high", + "medium_high": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::fanlevel::state::medium_high%]", "high": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::high%]", "auto": "[%key:component::climate::entity_component::_::state_attributes::fan_mode::state::auto%]" } @@ -358,16 +358,16 @@ "swing_mode": { "state": { "stopped": "[%key:common::state::off%]", - "fixedtop": "Fixed top", - "fixedmiddletop": "Fixed middle top", - "fixedmiddle": "Fixed middle", - "fixedmiddlebottom": "Fixed middle bottom", - "fixedbottom": "Fixed bottom", - "rangetop": "Range top", - "rangemiddle": "Range middle", - "rangebottom": "Range bottom", + "fixedtop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedtop%]", + "fixedmiddletop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddletop%]", + "fixedmiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddle%]", + "fixedmiddlebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedmiddlebottom%]", + "fixedbottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::fixedbottom%]", + "rangetop": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangetop%]", + "rangemiddle": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangemiddle%]", + "rangebottom": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::rangebottom%]", "rangefull": "[%key:component::sensibo::entity::select::horizontalswing::state::rangefull%]", - "horizontal": "Horizontal", + "horizontal": "[%key:component::sensibo::entity::sensor::climate_react_low::state_attributes::swing::state::horizontal%]", "both": "[%key:component::climate::entity_component::_::state_attributes::swing_mode::state::both%]" } } From a92919b8fde4a94fccf4f070ed11aa0c684edac2 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 12 Oct 2023 19:50:29 +0200 Subject: [PATCH 400/968] Bump reolink-aio to 0.7.11 (#101886) --- 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 221a6b8b59d..9d9d8d59e88 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.7.10"] + "requirements": ["reolink-aio==0.7.11"] } diff --git a/requirements_all.txt b/requirements_all.txt index cbb67c2620b..d0b6b7ab6bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2315,7 +2315,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.10 +reolink-aio==0.7.11 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb34a2d466a..889d6de2da9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1726,7 +1726,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.10 +reolink-aio==0.7.11 # homeassistant.components.rflink rflink==0.0.65 From 85af452c6e84ac0aecf070303e24e1f062218071 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 12 Oct 2023 19:51:43 +0200 Subject: [PATCH 401/968] Remove unnecessary dict lookup in fibaro integration (#101885) --- homeassistant/components/fibaro/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 29fc2c5b774..90b1f0a5425 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -227,11 +227,8 @@ class FibaroController: def get_siblings(self, device: DeviceModel) -> list[DeviceModel]: """Get the siblings of a device.""" if device.has_endpoint_id: - return self.get_children2( - self._device_map[device.fibaro_id].parent_fibaro_id, - self._device_map[device.fibaro_id].endpoint_id, - ) - return self.get_children(self._device_map[device.fibaro_id].parent_fibaro_id) + return self.get_children2(device.parent_fibaro_id, device.endpoint_id) + return self.get_children(device.parent_fibaro_id) @staticmethod def _map_device_to_platform(device: DeviceModel) -> Platform | None: From 0c901435bdb300551b08291b8ce1f01bb25db2b1 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Thu, 12 Oct 2023 19:57:15 +0200 Subject: [PATCH 402/968] Fix state_class of huisbaasje sensors (#101892) --- homeassistant/components/huisbaasje/sensor.py | 8 ++++---- tests/components/huisbaasje/test_sensor.py | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 8bc86d423a1..6e3f5eaee33 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ SENSORS_INFO = [ translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ SENSORS_INFO = [ translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ SENSORS_INFO = [ translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ SENSORS_INFO = [ translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.MEASUREMENT, + state_class=SensorStateClass.TOTAL_INCREASING, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index b324a5be970..484dc8bac48 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -224,7 +224,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -240,7 +240,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -256,7 +256,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -272,7 +272,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.MEASUREMENT + is SensorStateClass.TOTAL_INCREASING ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) From c4ce9005677ea14e607749adfe692660ba66b377 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 12 Oct 2023 20:06:03 +0200 Subject: [PATCH 403/968] Use CountrySelector in Prosegur Alarm (#101889) --- homeassistant/components/prosegur/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index ea975529b01..ac2b704b012 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -21,7 +21,9 @@ STEP_USER_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_COUNTRY): vol.In(COUNTRY.keys()), + vol.Required(CONF_COUNTRY): selector.CountrySelector( + selector.CountrySelectorConfig(countries=list(COUNTRY)) + ), } ) From cc3d1a11bd82899109ad221605b3ce55d041252b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 08:43:53 -1000 Subject: [PATCH 404/968] Add more typing to HomeKit (#101896) --- .../components/homekit/iidmanager.py | 2 +- .../components/homekit/type_cameras.py | 51 +++++++++++++------ .../components/homekit/type_humidifiers.py | 19 +++++-- .../components/homekit/type_lights.py | 19 ++++--- .../components/homekit/type_switches.py | 49 ++++++++++-------- 5 files changed, 90 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/homekit/iidmanager.py b/homeassistant/components/homekit/iidmanager.py index 2bd50821138..f44d76d3ee7 100644 --- a/homeassistant/components/homekit/iidmanager.py +++ b/homeassistant/components/homekit/iidmanager.py @@ -36,7 +36,7 @@ class IIDStorage(Store): old_major_version: int, old_minor_version: int, old_data: dict, - ): + ) -> dict: """Migrate to the new version.""" if old_major_version == 1: # Convert v1 to v2 format which uses a unique iid set per accessory diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 63b2bc023da..6bc8e785c7f 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from haffmpeg.core import FFMPEG_STDERR, HAFFmpeg from pyhap.camera import ( @@ -14,7 +15,7 @@ from pyhap.const import CATEGORY_CAMERA from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON -from homeassistant.core import State, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.event import ( EventStateChangedData, async_track_state_change_event, @@ -22,7 +23,7 @@ from homeassistant.helpers.event import ( ) from homeassistant.helpers.typing import EventType -from .accessories import TYPES, HomeAccessory +from .accessories import TYPES, HomeAccessory, HomeDriver from .const import ( CHAR_MOTION_DETECTED, CHAR_MUTE, @@ -141,7 +142,15 @@ CONFIG_DEFAULTS = { class Camera(HomeAccessory, PyhapCamera): """Generate a Camera accessory.""" - def __init__(self, hass, driver, name, entity_id, aid, config): + def __init__( + self, + hass: HomeAssistant, + driver: HomeDriver, + name: str, + entity_id: str, + aid: int, + config: dict[str, Any], + ) -> None: """Initialize a Camera accessory object.""" self._ffmpeg = get_ffmpeg_manager(hass) for config_key, conf in CONFIG_DEFAULTS.items(): @@ -242,12 +251,13 @@ class Camera(HomeAccessory, PyhapCamera): self._async_update_doorbell_state(state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. """ if self._char_motion_detected: + assert self.linked_motion_sensor self._subscriptions.append( async_track_state_change_event( self.hass, @@ -257,6 +267,7 @@ class Camera(HomeAccessory, PyhapCamera): ) if self._char_doorbell_detected: + assert self.linked_doorbell_sensor self._subscriptions.append( async_track_state_change_event( self.hass, @@ -282,6 +293,7 @@ class Camera(HomeAccessory, PyhapCamera): return detected = new_state.state == STATE_ON + assert self._char_motion_detected if self._char_motion_detected.value == detected: return @@ -307,6 +319,8 @@ class Camera(HomeAccessory, PyhapCamera): if not new_state: return + assert self._char_doorbell_detected + assert self._char_doorbell_detected_switch if new_state.state == STATE_ON: self._char_doorbell_detected.set_value(DOORBELL_SINGLE_PRESS) self._char_doorbell_detected_switch.set_value(DOORBELL_SINGLE_PRESS) @@ -318,11 +332,10 @@ class Camera(HomeAccessory, PyhapCamera): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State | None) -> None: """Handle state change to update HomeKit value.""" - pass # pylint: disable=unnecessary-pass - async def _async_get_stream_source(self): + async def _async_get_stream_source(self) -> str | None: """Find the camera stream source url.""" if stream_source := self.config.get(CONF_STREAM_SOURCE): return stream_source @@ -337,7 +350,9 @@ class Camera(HomeAccessory, PyhapCamera): ) return stream_source - async def start_stream(self, session_info, stream_config): + async def start_stream( + self, session_info: dict[str, Any], stream_config: dict[str, Any] + ) -> bool: """Start a new stream with the given configuration.""" _LOGGER.debug( "[%s] Starting stream with the following parameters: %s", @@ -418,7 +433,9 @@ class Camera(HomeAccessory, PyhapCamera): return await self._async_ffmpeg_watch(session_info["id"]) - async def _async_log_stderr_stream(self, stderr_reader): + async def _async_log_stderr_stream( + self, stderr_reader: asyncio.StreamReader + ) -> None: """Log output from ffmpeg.""" _LOGGER.debug("%s: ffmpeg: started", self.display_name) while True: @@ -428,7 +445,7 @@ class Camera(HomeAccessory, PyhapCamera): _LOGGER.debug("%s: ffmpeg: %s", self.display_name, line.rstrip()) - async def _async_ffmpeg_watch(self, session_id): + async def _async_ffmpeg_watch(self, session_id: str) -> bool: """Check to make sure ffmpeg is still running and cleanup if not.""" ffmpeg_pid = self.sessions[session_id][FFMPEG_PID] if pid_is_alive(ffmpeg_pid): @@ -440,7 +457,7 @@ class Camera(HomeAccessory, PyhapCamera): return False @callback - def _async_stop_ffmpeg_watch(self, session_id): + def _async_stop_ffmpeg_watch(self, session_id: str) -> None: """Cleanup a streaming session after stopping.""" if FFMPEG_WATCHER not in self.sessions[session_id]: return @@ -448,7 +465,7 @@ class Camera(HomeAccessory, PyhapCamera): self.sessions[session_id].pop(FFMPEG_LOGGER).cancel() @callback - def async_stop(self): + def async_stop(self) -> None: """Stop any streams when the accessory is stopped.""" for session_info in self.sessions.values(): self.hass.async_create_background_task( @@ -456,7 +473,7 @@ class Camera(HomeAccessory, PyhapCamera): ) super().async_stop() - async def stop_stream(self, session_info): + async def stop_stream(self, session_info: dict[str, Any]) -> None: """Stop the stream for the given ``session_id``.""" session_id = session_info["id"] if not (stream := session_info.get("stream")): @@ -467,7 +484,7 @@ class Camera(HomeAccessory, PyhapCamera): if not pid_is_alive(stream.process.pid): _LOGGER.info("[%s] Stream already stopped", session_id) - return True + return for shutdown_method in ("close", "kill"): _LOGGER.info("[%s] %s stream", session_id, shutdown_method) @@ -479,11 +496,13 @@ class Camera(HomeAccessory, PyhapCamera): "[%s] Failed to %s stream", session_id, shutdown_method ) - async def reconfigure_stream(self, session_info, stream_config): + async def reconfigure_stream( + self, session_info: dict[str, Any], stream_config: dict[str, Any] + ) -> bool: """Reconfigure the stream so that it uses the given ``stream_config``.""" return True - async def async_get_snapshot(self, image_size): + async def async_get_snapshot(self, image_size: dict[str, int]) -> bytes: """Return a jpeg of a snapshot from the camera.""" image = await camera.async_get_image( self.hass, diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index de25717877c..d296b293820 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -1,5 +1,6 @@ """Class to hold all thermostat accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_HUMIDIFIER @@ -73,7 +74,7 @@ HC_STATE_DEHUMIDIFYING = 3 class HumidifierDehumidifier(HomeAccessory): """Generate a HumidifierDehumidifier accessory for a humidifier.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a HumidifierDehumidifier accessory object.""" super().__init__(*args, category=CATEGORY_HUMIDIFIER) self._reload_on_change_attrs.extend( @@ -83,8 +84,9 @@ class HumidifierDehumidifier(HomeAccessory): ) ) - self.chars = [] + self.chars: list[str] = [] state = self.hass.states.get(self.entity_id) + assert state device_class = state.attributes.get( ATTR_DEVICE_CLASS, HumidifierDeviceClass.HUMIDIFIER ) @@ -151,7 +153,7 @@ class HumidifierDehumidifier(HomeAccessory): if humidity_state: self._async_update_current_humidity(humidity_state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -205,7 +207,8 @@ class HumidifierDehumidifier(HomeAccessory): ex, ) - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: + """Set characteristics based on the data coming from HomeKit.""" _LOGGER.debug("HumidifierDehumidifier _set_chars: %s", char_values) if CHAR_TARGET_HUMIDIFIER_DEHUMIDIFIER in char_values: @@ -225,6 +228,7 @@ class HumidifierDehumidifier(HomeAccessory): if self._target_humidity_char_name in char_values: state = self.hass.states.get(self.entity_id) + assert state max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) max_humidity = round(max_humidity) max_humidity = min(max_humidity, 100) @@ -232,6 +236,11 @@ class HumidifierDehumidifier(HomeAccessory): min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) min_humidity = round(min_humidity) min_humidity = max(min_humidity, 0) + # The min/max humidity values here should be clamped to the HomeKit + # min/max that was set when the accessory was added to HomeKit so + # that the user cannot set a value outside of the range that was + # originally set as it could cause HomeKit to report the accessory + # as not responding. humidity = round(char_values[self._target_humidity_char_name]) @@ -252,7 +261,7 @@ class HumidifierDehumidifier(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" is_active = new_state.state == STATE_ON diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index e8272358633..13301c3f507 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -1,7 +1,9 @@ """Class to hold all light accessories.""" from __future__ import annotations +from datetime import datetime import logging +from typing import Any from pyhap.const import CATEGORY_LIGHTBULB @@ -29,7 +31,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, State, callback from homeassistant.helpers.event import async_call_later from homeassistant.util.color import ( color_temperature_kelvin_to_mired, @@ -68,7 +70,7 @@ class Light(HomeAccessory): Currently supports: state, brightness, color temperature, rgb_color. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) self._reload_on_change_attrs.extend( @@ -79,10 +81,11 @@ class Light(HomeAccessory): ) ) self.chars = [] - self._event_timer = None - self._pending_events = {} + self._event_timer: CALLBACK_TYPE | None = None + self._pending_events: dict[str, Any] = {} state = self.hass.states.get(self.entity_id) + assert state attributes = state.attributes self.color_modes = color_modes = ( attributes.get(ATTR_SUPPORTED_COLOR_MODES) or [] @@ -140,7 +143,7 @@ class Light(HomeAccessory): self.async_update_state(state) serv_light.setter_callback = self._set_chars - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Light _set_chars: %s", char_values) # Newest change always wins if CHAR_COLOR_TEMPERATURE in self._pending_events and ( @@ -159,14 +162,14 @@ class Light(HomeAccessory): ) @callback - def _async_send_events(self, *_): + def _async_send_events(self, _now: datetime) -> None: """Process all changes at once.""" _LOGGER.debug("Coalesced _set_chars: %s", self._pending_events) char_values = self._pending_events self._pending_events = {} events = [] service = SERVICE_TURN_ON - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} if CHAR_ON in char_values: if not char_values[CHAR_ON]: @@ -231,7 +234,7 @@ class Light(HomeAccessory): self.async_call_service(DOMAIN, service, params, ", ".join(events)) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update light after state change.""" # Handle State state = new_state.state diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index f79185c64b1..5c0c2c74f0a 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging -from typing import NamedTuple +from typing import Any, NamedTuple +from pyhap.characteristic import Characteristic from pyhap.const import ( CATEGORY_FAUCET, CATEGORY_OUTLET, @@ -30,7 +31,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_ON, ) -from homeassistant.core import callback, split_entity_id +from homeassistant.core import State, callback, split_entity_id from homeassistant.helpers.event import async_call_later from .accessories import TYPES, HomeAccessory @@ -78,10 +79,11 @@ ACTIVATE_ONLY_RESET_SECONDS = 10 class Outlet(HomeAccessory): """Generate an Outlet accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize an Outlet accessory object.""" super().__init__(*args, category=CATEGORY_OUTLET) state = self.hass.states.get(self.entity_id) + assert state serv_outlet = self.add_preload_service(SERV_OUTLET) self.char_on = serv_outlet.configure_char( @@ -94,7 +96,7 @@ class Outlet(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id} @@ -102,7 +104,7 @@ class Outlet(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state == STATE_ON _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) @@ -113,13 +115,14 @@ class Outlet(HomeAccessory): class Switch(HomeAccessory): """Generate a Switch accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self._domain, self._object_id = split_entity_id(self.entity_id) state = self.hass.states.get(self.entity_id) + assert state - self.activate_only = self.is_activate(self.hass.states.get(self.entity_id)) + self.activate_only = self.is_activate(state) serv_switch = self.add_preload_service(SERV_SWITCH) self.char_on = serv_switch.configure_char( @@ -129,16 +132,16 @@ class Switch(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def is_activate(self, state): + def is_activate(self, state: State) -> bool: """Check if entity is activate only.""" return self._domain in ACTIVATE_ONLY_SWITCH_DOMAINS - def reset_switch(self, *args): + def reset_switch(self, *args: Any) -> None: """Reset switch to emulate activate click.""" _LOGGER.debug("%s: Reset switch to off", self.entity_id) self.char_on.set_value(False) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) if self.activate_only and not value: @@ -162,7 +165,7 @@ class Switch(HomeAccessory): async_call_later(self.hass, ACTIVATE_ONLY_RESET_SECONDS, self.reset_switch) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" self.activate_only = self.is_activate(new_state) if self.activate_only: @@ -180,10 +183,12 @@ class Switch(HomeAccessory): class Vacuum(Switch): """Generate a Switch accessory.""" - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) state = self.hass.states.get(self.entity_id) + assert state + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if value: @@ -198,7 +203,7 @@ class Vacuum(Switch): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state in (STATE_CLEANING, STATE_ON) _LOGGER.debug("%s: Set current state to %s", self.entity_id, current_state) @@ -209,10 +214,12 @@ class Vacuum(Switch): class Valve(HomeAccessory): """Generate a Valve accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Valve accessory object.""" super().__init__(*args) state = self.hass.states.get(self.entity_id) + assert state + valve_type = self.config[CONF_TYPE] self.category = VALVE_TYPE[valve_type].category @@ -228,7 +235,7 @@ class Valve(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: bool) -> None: """Move value state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set switch state to %s", self.entity_id, value) self.char_in_use.set_value(value) @@ -237,7 +244,7 @@ class Valve(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = 1 if new_state.state == STATE_ON else 0 _LOGGER.debug("%s: Set active state to %s", self.entity_id, current_state) @@ -250,12 +257,14 @@ class Valve(HomeAccessory): class SelectSwitch(HomeAccessory): """Generate a Switch accessory that contains multiple switches.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self.domain = split_entity_id(self.entity_id)[0] state = self.hass.states.get(self.entity_id) - self.select_chars = {} + assert state + + self.select_chars: dict[str, Characteristic] = {} options = state.attributes[ATTR_OPTIONS] for option in options: serv_option = self.add_preload_service( @@ -275,14 +284,14 @@ class SelectSwitch(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def select_option(self, option): + def select_option(self, option: str) -> None: """Set option from HomeKit.""" _LOGGER.debug("%s: Set option to %s", self.entity_id, option) params = {ATTR_ENTITY_ID: self.entity_id, "option": option} self.async_call_service(self.domain, SERVICE_SELECT_OPTION, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_option = cleanup_name_for_homekit(new_state.state) for option, char in self.select_chars.items(): From 536ad57bf48ceabfd476c9c77184a504a39db994 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 12 Oct 2023 21:58:22 +0300 Subject: [PATCH 405/968] Use DataUpdate coordinator for Transmission (#99209) * Switch integration to DataUpdate Coordinator * add coordinator to .coveragerc * Migrate TransmissionData into DUC * update coveragerc * Applu suggestions * remove CONFIG_SCHEMA --- .coveragerc | 1 + .../components/transmission/__init__.py | 418 ++++-------------- .../components/transmission/const.py | 2 - .../components/transmission/coordinator.py | 166 +++++++ .../components/transmission/sensor.py | 125 ++---- .../components/transmission/switch.py | 93 ++-- 6 files changed, 340 insertions(+), 465 deletions(-) create mode 100644 homeassistant/components/transmission/coordinator.py diff --git a/.coveragerc b/.coveragerc index 3e8de435832..41b46796373 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1396,6 +1396,7 @@ omit = homeassistant/components/trafikverket_weatherstation/coordinator.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/__init__.py + homeassistant/components/transmission/coordinator.py homeassistant/components/transmission/sensor.py homeassistant/components/transmission/switch.py homeassistant/components/travisci/sensor.py diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 7e02c3d419d..be32c95356d 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,8 +1,7 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import timedelta from functools import partial import logging import re @@ -14,10 +13,9 @@ from transmission_rpc.error import ( TransmissionConnectError, TransmissionError, ) -from transmission_rpc.session import SessionStats import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -35,33 +33,39 @@ from homeassistant.helpers import ( entity_registry as er, selector, ) -from homeassistant.helpers.dispatcher import dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from .const import ( ATTR_DELETE_DATA, ATTR_TORRENT, CONF_ENTRY_ID, - CONF_LIMIT, - CONF_ORDER, - DATA_UPDATED, DEFAULT_DELETE_DATA, - DEFAULT_LIMIT, - DEFAULT_ORDER, - DEFAULT_SCAN_INTERVAL, DOMAIN, - EVENT_DOWNLOADED_TORRENT, - EVENT_REMOVED_TORRENT, - EVENT_STARTED_TORRENT, SERVICE_ADD_TORRENT, SERVICE_REMOVE_TORRENT, SERVICE_START_TORRENT, SERVICE_STOP_TORRENT, ) +from .coordinator import TransmissionDataUpdateCoordinator from .errors import AuthenticationError, CannotConnect, UnknownError _LOGGER = logging.getLogger(__name__) +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] + +MIGRATION_NAME_TO_KEY = { + # Sensors + "Down Speed": "download", + "Up Speed": "upload", + "Status": "status", + "Active Torrents": "active_torrents", + "Paused Torrents": "paused_torrents", + "Total Torrents": "total_torrents", + "Completed Torrents": "completed_torrents", + "Started Torrents": "started_torrents", + # Switches + "Switch": "on_off", + "Turtle Mode": "turtle_mode", +} SERVICE_BASE_SCHEMA = vol.Schema( { @@ -95,25 +99,6 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.All( ) ) -CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - -PLATFORMS = [Platform.SENSOR, Platform.SWITCH] - -MIGRATION_NAME_TO_KEY = { - # Sensors - "Down Speed": "download", - "Up Speed": "upload", - "Status": "status", - "Active Torrents": "active_torrents", - "Paused Torrents": "paused_torrents", - "Total Torrents": "total_torrents", - "Completed Torrents": "completed_torrents", - "Started Torrents": "started_torrents", - # Switches - "Switch": "on_off", - "Turtle Mode": "turtle_mode", -} - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Transmission Component.""" @@ -141,24 +126,81 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b except (AuthenticationError, UnknownError) as error: raise ConfigEntryAuthFailed from error - client = TransmissionClient(hass, config_entry, api) - await client.async_setup() - hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client + coordinator = TransmissionDataUpdateCoordinator(hass, config_entry, api) + await hass.async_add_executor_job(coordinator.init_torrent_list) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - client.register_services() + config_entry.add_update_listener(async_options_updated) + + async def add_torrent(service: ServiceCall) -> None: + """Add new torrent to download.""" + torrent = service.data[ATTR_TORRENT] + if torrent.startswith( + ("http", "ftp:", "magnet:") + ) or hass.config.is_allowed_path(torrent): + await hass.async_add_executor_job(coordinator.api.add_torrent, torrent) + await coordinator.async_request_refresh() + else: + _LOGGER.warning("Could not add torrent: unsupported type or no permission") + + async def start_torrent(service: ServiceCall) -> None: + """Start torrent.""" + torrent_id = service.data[CONF_ID] + await hass.async_add_executor_job(coordinator.api.start_torrent, torrent_id) + await coordinator.async_request_refresh() + + async def stop_torrent(service: ServiceCall) -> None: + """Stop torrent.""" + torrent_id = service.data[CONF_ID] + await hass.async_add_executor_job(coordinator.api.stop_torrent, torrent_id) + await coordinator.async_request_refresh() + + async def remove_torrent(service: ServiceCall) -> None: + """Remove torrent.""" + torrent_id = service.data[CONF_ID] + delete_data = service.data[ATTR_DELETE_DATA] + await hass.async_add_executor_job( + partial(coordinator.api.remove_torrent, torrent_id, delete_data=delete_data) + ) + await coordinator.async_request_refresh() + + hass.services.async_register( + DOMAIN, SERVICE_ADD_TORRENT, add_torrent, schema=SERVICE_ADD_TORRENT_SCHEMA + ) + + hass.services.async_register( + DOMAIN, + SERVICE_REMOVE_TORRENT, + remove_torrent, + schema=SERVICE_REMOVE_TORRENT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_START_TORRENT, + start_torrent, + schema=SERVICE_START_TORRENT_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_STOP_TORRENT, + stop_torrent, + schema=SERVICE_STOP_TORRENT_SCHEMA, + ) + return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Transmission Entry from config_entry.""" - client: TransmissionClient = hass.data[DOMAIN].pop(config_entry.entry_id) - if client.unsub_timer: - client.unsub_timer() - - unload_ok = await hass.config_entries.async_unload_platforms( + if unload_ok := await hass.config_entries.async_unload_platforms( config_entry, PLATFORMS - ) + ): + hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: hass.services.async_remove(DOMAIN, SERVICE_ADD_TORRENT) @@ -202,286 +244,8 @@ async def get_api( raise UnknownError from error -def _get_client(hass: HomeAssistant, data: dict[str, Any]) -> TransmissionClient | None: - """Return client from integration name or entry_id.""" - if ( - (entry_id := data.get(CONF_ENTRY_ID)) - and (entry := hass.config_entries.async_get_entry(entry_id)) - and entry.state == ConfigEntryState.LOADED - ): - return hass.data[DOMAIN][entry_id] - - return None - - -class TransmissionClient: - """Transmission Client Object.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - api: transmission_rpc.Client, - ) -> None: - """Initialize the Transmission RPC API.""" - self.hass = hass - self.config_entry = config_entry - self.tm_api = api - self._tm_data = TransmissionData(hass, config_entry, api) - self.unsub_timer: Callable[[], None] | None = None - - @property - def api(self) -> TransmissionData: - """Return the TransmissionData object.""" - return self._tm_data - - async def async_setup(self) -> None: - """Set up the Transmission client.""" - await self.hass.async_add_executor_job(self.api.init_torrent_list) - await self.hass.async_add_executor_job(self.api.update) - self.add_options() - self.set_scan_interval(self.config_entry.options[CONF_SCAN_INTERVAL]) - - def register_services(self) -> None: - """Register integration services.""" - - def add_torrent(service: ServiceCall) -> None: - """Add new torrent to download.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent = service.data[ATTR_TORRENT] - if torrent.startswith( - ("http", "ftp:", "magnet:") - ) or self.hass.config.is_allowed_path(torrent): - tm_client.tm_api.add_torrent(torrent) - tm_client.api.update() - else: - _LOGGER.warning( - "Could not add torrent: unsupported type or no permission" - ) - - def start_torrent(service: ServiceCall) -> None: - """Start torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - tm_client.tm_api.start_torrent(torrent_id) - tm_client.api.update() - - def stop_torrent(service: ServiceCall) -> None: - """Stop torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - tm_client.tm_api.stop_torrent(torrent_id) - tm_client.api.update() - - def remove_torrent(service: ServiceCall) -> None: - """Remove torrent.""" - if not (tm_client := _get_client(self.hass, service.data)): - raise ValueError("Transmission instance is not found") - - torrent_id = service.data[CONF_ID] - delete_data = service.data[ATTR_DELETE_DATA] - tm_client.tm_api.remove_torrent(torrent_id, delete_data=delete_data) - tm_client.api.update() - - self.hass.services.async_register( - DOMAIN, SERVICE_ADD_TORRENT, add_torrent, schema=SERVICE_ADD_TORRENT_SCHEMA - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_REMOVE_TORRENT, - remove_torrent, - schema=SERVICE_REMOVE_TORRENT_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_START_TORRENT, - start_torrent, - schema=SERVICE_START_TORRENT_SCHEMA, - ) - - self.hass.services.async_register( - DOMAIN, - SERVICE_STOP_TORRENT, - stop_torrent, - schema=SERVICE_STOP_TORRENT_SCHEMA, - ) - - self.config_entry.add_update_listener(self.async_options_updated) - - def add_options(self): - """Add options for entry.""" - if not self.config_entry.options: - scan_interval = self.config_entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ) - limit = self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT) - order = self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER) - options = { - CONF_SCAN_INTERVAL: scan_interval, - CONF_LIMIT: limit, - CONF_ORDER: order, - } - - self.hass.config_entries.async_update_entry( - self.config_entry, options=options - ) - - def set_scan_interval(self, scan_interval: float) -> None: - """Update scan interval.""" - - def refresh(event_time: datetime) -> None: - """Get the latest data from Transmission.""" - self.api.update() - - if self.unsub_timer is not None: - self.unsub_timer() - self.unsub_timer = async_track_time_interval( - self.hass, refresh, timedelta(seconds=scan_interval) - ) - - @staticmethod - async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - tm_client: TransmissionClient = hass.data[DOMAIN][entry.entry_id] - tm_client.set_scan_interval(entry.options[CONF_SCAN_INTERVAL]) - await hass.async_add_executor_job(tm_client.api.update) - - -class TransmissionData: - """Get the latest data and update the states.""" - - def __init__( - self, hass: HomeAssistant, config: ConfigEntry, api: transmission_rpc.Client - ) -> None: - """Initialize the Transmission RPC API.""" - self.hass = hass - self.config = config - self._api: transmission_rpc.Client = api - self.data: SessionStats | None = None - self.available: bool = True - self._session: transmission_rpc.Session | None = None - self._all_torrents: list[transmission_rpc.Torrent] = [] - self._completed_torrents: list[transmission_rpc.Torrent] = [] - self._started_torrents: list[transmission_rpc.Torrent] = [] - self._torrents: list[transmission_rpc.Torrent] = [] - - @property - def host(self) -> str: - """Return the host name.""" - return self.config.data[CONF_HOST] - - @property - def signal_update(self) -> str: - """Update signal per transmission entry.""" - return f"{DATA_UPDATED}-{self.host}" - - @property - def torrents(self) -> list[transmission_rpc.Torrent]: - """Get the list of torrents.""" - return self._torrents - - def update(self) -> None: - """Get the latest data from Transmission instance.""" - try: - self.data = self._api.session_stats() - self._torrents = self._api.get_torrents() - self._session = self._api.get_session() - - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() - _LOGGER.debug("Torrent Data for %s Updated", self.host) - - self.available = True - except TransmissionError: - self.available = False - _LOGGER.error("Unable to connect to Transmission client %s", self.host) - dispatcher_send(self.hass, self.signal_update) - - def init_torrent_list(self) -> None: - """Initialize torrent lists.""" - self._torrents = self._api.get_torrents() - self._completed_torrents = [ - torrent for torrent in self._torrents if torrent.status == "seeding" - ] - self._started_torrents = [ - torrent for torrent in self._torrents if torrent.status == "downloading" - ] - - def check_completed_torrent(self) -> None: - """Get completed torrent functionality.""" - old_completed_torrent_names = { - torrent.name for torrent in self._completed_torrents - } - - current_completed_torrents = [ - torrent for torrent in self._torrents if torrent.status == "seeding" - ] - - for torrent in current_completed_torrents: - if torrent.name not in old_completed_torrent_names: - self.hass.bus.fire( - EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._completed_torrents = current_completed_torrents - - def check_started_torrent(self) -> None: - """Get started torrent functionality.""" - old_started_torrent_names = {torrent.name for torrent in self._started_torrents} - - current_started_torrents = [ - torrent for torrent in self._torrents if torrent.status == "downloading" - ] - - for torrent in current_started_torrents: - if torrent.name not in old_started_torrent_names: - self.hass.bus.fire( - EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._started_torrents = current_started_torrents - - def check_removed_torrent(self) -> None: - """Get removed torrent functionality.""" - current_torrent_names = {torrent.name for torrent in self._torrents} - - for torrent in self._all_torrents: - if torrent.name not in current_torrent_names: - self.hass.bus.fire( - EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} - ) - - self._all_torrents = self._torrents.copy() - - def start_torrents(self) -> None: - """Start all torrents.""" - if not self._torrents: - return - self._api.start_all() - - def stop_torrents(self) -> None: - """Stop all active torrents.""" - if not self._torrents: - return - torrent_ids = [torrent.id for torrent in self._torrents] - self._api.stop_torrent(torrent_ids) - - def set_alt_speed_enabled(self, is_enabled: bool) -> None: - """Set the alternative speed flag.""" - self._api.set_session(alt_speed_enabled=is_enabled) - - def get_alt_speed_enabled(self) -> bool | None: - """Get the alternative speed flag.""" - if self._session is None: - return None - - return self._session.alt_speed_enabled +async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Triggered by config entry options updates.""" + coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) + await coordinator.async_request_refresh() diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index da861d2698c..cb31d5a5aac 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -39,8 +39,6 @@ SERVICE_REMOVE_TORRENT = "remove_torrent" SERVICE_START_TORRENT = "start_torrent" SERVICE_STOP_TORRENT = "stop_torrent" -DATA_UPDATED = "transmission_data_updated" - EVENT_STARTED_TORRENT = "transmission_started_torrent" EVENT_REMOVED_TORRENT = "transmission_removed_torrent" EVENT_DOWNLOADED_TORRENT = "transmission_downloaded_torrent" diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py new file mode 100644 index 00000000000..5fce7cae53d --- /dev/null +++ b/homeassistant/components/transmission/coordinator.py @@ -0,0 +1,166 @@ +"""Coordinator for transmssion integration.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import transmission_rpc +from transmission_rpc.session import SessionStats + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + CONF_LIMIT, + CONF_ORDER, + DEFAULT_LIMIT, + DEFAULT_ORDER, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + EVENT_DOWNLOADED_TORRENT, + EVENT_REMOVED_TORRENT, + EVENT_STARTED_TORRENT, +) + +_LOGGER = logging.getLogger(__name__) + + +class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): + """Transmission dataupdate coordinator class.""" + + config_entry: ConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, api: transmission_rpc.Client + ) -> None: + """Initialize the Transmission RPC API.""" + self.config_entry = entry + self.api = api + self.host = entry.data[CONF_HOST] + self._session: transmission_rpc.Session | None = None + self._all_torrents: list[transmission_rpc.Torrent] = [] + self._completed_torrents: list[transmission_rpc.Torrent] = [] + self._started_torrents: list[transmission_rpc.Torrent] = [] + self.torrents: list[transmission_rpc.Torrent] = [] + super().__init__( + hass, + name=f"{DOMAIN} - {self.host}", + logger=_LOGGER, + update_interval=timedelta(seconds=self.scan_interval), + ) + + @property + def scan_interval(self) -> float: + """Return scan interval.""" + return self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + + @property + def limit(self) -> int: + """Return limit.""" + return self.config_entry.data.get(CONF_LIMIT, DEFAULT_LIMIT) + + @property + def order(self) -> str: + """Return order.""" + return self.config_entry.data.get(CONF_ORDER, DEFAULT_ORDER) + + async def _async_update_data(self) -> SessionStats: + """Update transmission data.""" + return await self.hass.async_add_executor_job(self.update) + + def update(self) -> SessionStats: + """Get the latest data from Transmission instance.""" + try: + data = self.api.session_stats() + self.torrents = self.api.get_torrents() + self._session = self.api.get_session() + + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + except transmission_rpc.TransmissionError as err: + raise UpdateFailed("Unable to connect to Transmission client") from err + + return data + + def init_torrent_list(self) -> None: + """Initialize torrent lists.""" + self.torrents = self.api.get_torrents() + self._completed_torrents = [ + torrent for torrent in self.torrents if torrent.status == "seeding" + ] + self._started_torrents = [ + torrent for torrent in self.torrents if torrent.status == "downloading" + ] + + def check_completed_torrent(self) -> None: + """Get completed torrent functionality.""" + old_completed_torrent_names = { + torrent.name for torrent in self._completed_torrents + } + + current_completed_torrents = [ + torrent for torrent in self.torrents if torrent.status == "seeding" + ] + + for torrent in current_completed_torrents: + if torrent.name not in old_completed_torrent_names: + self.hass.bus.fire( + EVENT_DOWNLOADED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._completed_torrents = current_completed_torrents + + def check_started_torrent(self) -> None: + """Get started torrent functionality.""" + old_started_torrent_names = {torrent.name for torrent in self._started_torrents} + + current_started_torrents = [ + torrent for torrent in self.torrents if torrent.status == "downloading" + ] + + for torrent in current_started_torrents: + if torrent.name not in old_started_torrent_names: + self.hass.bus.fire( + EVENT_STARTED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._started_torrents = current_started_torrents + + def check_removed_torrent(self) -> None: + """Get removed torrent functionality.""" + current_torrent_names = {torrent.name for torrent in self.torrents} + + for torrent in self._all_torrents: + if torrent.name not in current_torrent_names: + self.hass.bus.fire( + EVENT_REMOVED_TORRENT, {"name": torrent.name, "id": torrent.id} + ) + + self._all_torrents = self.torrents.copy() + + def start_torrents(self) -> None: + """Start all torrents.""" + if not self.torrents: + return + self.api.start_all() + + def stop_torrents(self) -> None: + """Stop all active torrents.""" + if not self.torrents: + return + torrent_ids = [torrent.id for torrent in self.torrents] + self.api.stop_torrent(torrent_ids) + + def set_alt_speed_enabled(self, is_enabled: bool) -> None: + """Set the alternative speed flag.""" + self.api.set_session(alt_speed_enabled=is_enabled) + + def get_alt_speed_enabled(self) -> bool | None: + """Get the alternative speed flag.""" + if self._session is None: + return None + + return self._session.alt_speed_enabled diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 93bea8a25c9..0b949e73f47 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -4,21 +4,18 @@ from __future__ import annotations from contextlib import suppress from typing import Any +from transmission_rpc.session import SessionStats from transmission_rpc.torrent import Torrent from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_IDLE, UnitOfDataRate -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionClient from .const import ( - CONF_LIMIT, - CONF_ORDER, DOMAIN, STATE_ATTR_TORRENT_INFO, STATE_DOWNLOADING, @@ -26,6 +23,7 @@ from .const import ( STATE_UP_DOWN, SUPPORTED_ORDER_MODES, ) +from .coordinator import TransmissionDataUpdateCoordinator async def async_setup_entry( @@ -35,54 +33,56 @@ async def async_setup_entry( ) -> None: """Set up the Transmission sensors.""" - tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] + coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] name: str = config_entry.data[CONF_NAME] dev = [ TransmissionSpeedSensor( - tm_client, + coordinator, name, "download_speed", "download", ), TransmissionSpeedSensor( - tm_client, + coordinator, name, "upload_speed", "upload", ), TransmissionStatusSensor( - tm_client, + coordinator, name, "transmission_status", "status", ), TransmissionTorrentsSensor( - tm_client, + coordinator, name, "active_torrents", "active_torrents", ), TransmissionTorrentsSensor( - tm_client, + coordinator, name, "paused_torrents", "paused_torrents", ), TransmissionTorrentsSensor( - tm_client, + coordinator, name, "total_torrents", "total_torrents", ), TransmissionTorrentsSensor( - tm_client, + coordinator, name, "completed_torrents", "completed_torrents", ), TransmissionTorrentsSensor( - tm_client, + coordinator, name, "started_torrents", "started_torrents", @@ -92,7 +92,7 @@ async def async_setup_entry( async_add_entities(dev, True) -class TransmissionSensor(SensorEntity): +class TransmissionSensor(CoordinatorEntity[SessionStats], SensorEntity): """A base class for all Transmission sensors.""" _attr_has_entity_name = True @@ -100,48 +100,23 @@ class TransmissionSensor(SensorEntity): def __init__( self, - tm_client: TransmissionClient, + coordinator: TransmissionDataUpdateCoordinator, client_name: str, sensor_translation_key: str, key: str, ) -> None: """Initialize the sensor.""" - self._tm_client = tm_client + super().__init__(coordinator) self._attr_translation_key = sensor_translation_key self._key = key - self._state: StateType = None - self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{key}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, tm_client.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", name=client_name, ) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - return self._state - - @property - def available(self) -> bool: - """Could the device be accessed during the last update call.""" - return self._tm_client.api.available - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self.async_on_remove( - async_dispatcher_connect( - self.hass, self._tm_client.api.signal_update, update - ) - ) - class TransmissionSpeedSensor(TransmissionSensor): """Representation of a Transmission speed sensor.""" @@ -151,15 +126,15 @@ class TransmissionSpeedSensor(TransmissionSensor): _attr_suggested_display_precision = 2 _attr_suggested_unit_of_measurement = UnitOfDataRate.MEGABYTES_PER_SECOND - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - if data := self._tm_client.api.data: - b_spd = ( - float(data.download_speed) - if self._key == "download" - else float(data.upload_speed) - ) - self._state = b_spd + @property + def native_value(self) -> float: + """Return the speed of the sensor.""" + data = self.coordinator.data + return ( + float(data.download_speed) + if self._key == "download" + else float(data.upload_speed) + ) class TransmissionStatusSensor(TransmissionSensor): @@ -168,21 +143,18 @@ class TransmissionStatusSensor(TransmissionSensor): _attr_device_class = SensorDeviceClass.ENUM _attr_options = [STATE_IDLE, STATE_UP_DOWN, STATE_SEEDING, STATE_DOWNLOADING] - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - if data := self._tm_client.api.data: - upload = data.upload_speed - download = data.download_speed - if upload > 0 and download > 0: - self._state = STATE_UP_DOWN - elif upload > 0 and download == 0: - self._state = STATE_SEEDING - elif upload == 0 and download > 0: - self._state = STATE_DOWNLOADING - else: - self._state = STATE_IDLE - else: - self._state = None + @property + def native_value(self) -> str: + """Return the value of the status sensor.""" + upload = self.coordinator.data.upload_speed + download = self.coordinator.data.download_speed + if upload > 0 and download > 0: + return STATE_UP_DOWN + if upload > 0 and download == 0: + return STATE_SEEDING + if upload == 0 and download > 0: + return STATE_DOWNLOADING + return STATE_IDLE class TransmissionTorrentsSensor(TransmissionSensor): @@ -208,21 +180,22 @@ class TransmissionTorrentsSensor(TransmissionSensor): def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes, if any.""" info = _torrents_info( - torrents=self._tm_client.api.torrents, - order=self._tm_client.config_entry.options[CONF_ORDER], - limit=self._tm_client.config_entry.options[CONF_LIMIT], + torrents=self.coordinator.torrents, + order=self.coordinator.order, + limit=self.coordinator.limit, statuses=self.MODES[self._key], ) return { STATE_ATTR_TORRENT_INFO: info, } - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" + @property + def native_value(self) -> int: + """Return the count of the sensor.""" torrents = _filter_torrents( - self._tm_client.api.torrents, statuses=self.MODES[self._key] + self.coordinator.torrents, statuses=self.MODES[self._key] ) - self._state = len(torrents) + return len(torrents) def _filter_torrents( diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index fad099fc5b9..253ceb558b9 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -3,16 +3,18 @@ from collections.abc import Callable import logging from typing import Any +from transmission_rpc.session import SessionStats + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TransmissionClient from .const import DOMAIN, SWITCH_TYPES +from .coordinator import TransmissionDataUpdateCoordinator _LOGGING = logging.getLogger(__name__) @@ -24,17 +26,19 @@ async def async_setup_entry( ) -> None: """Set up the Transmission switch.""" - tm_client: TransmissionClient = hass.data[DOMAIN][config_entry.entry_id] + coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] name: str = config_entry.data[CONF_NAME] dev = [] for switch_type, switch_name in SWITCH_TYPES.items(): - dev.append(TransmissionSwitch(switch_type, switch_name, tm_client, name)) + dev.append(TransmissionSwitch(switch_type, switch_name, coordinator, name)) async_add_entities(dev, True) -class TransmissionSwitch(SwitchEntity): +class TransmissionSwitch(CoordinatorEntity[SessionStats], SwitchEntity): """Representation of a Transmission switch.""" _attr_has_entity_name = True @@ -44,20 +48,18 @@ class TransmissionSwitch(SwitchEntity): self, switch_type: str, switch_name: str, - tm_client: TransmissionClient, + coordinator: TransmissionDataUpdateCoordinator, client_name: str, ) -> None: """Initialize the Transmission switch.""" + super().__init__(coordinator) self._attr_name = switch_name self.type = switch_type - self._tm_client = tm_client - self._state = STATE_OFF - self._data = None self.unsub_update: Callable[[], None] | None = None - self._attr_unique_id = f"{tm_client.config_entry.entry_id}-{switch_type}" + self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{switch_type}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, tm_client.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", name=client_name, ) @@ -65,63 +67,34 @@ class TransmissionSwitch(SwitchEntity): @property def is_on(self) -> bool: """Return true if device is on.""" - return self._state == STATE_ON + active = None + if self.type == "on_off": + active = self.coordinator.data.active_torrent_count > 0 + elif self.type == "turtle_mode": + active = self.coordinator.get_alt_speed_enabled() - @property - def available(self) -> bool: - """Could the device be accessed during the last update call.""" - return self._tm_client.api.available + return bool(active) - def turn_on(self, **kwargs: Any) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if self.type == "on_off": _LOGGING.debug("Starting all torrents") - self._tm_client.api.start_torrents() + await self.hass.async_add_executor_job(self.coordinator.start_torrents) elif self.type == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission on") - self._tm_client.api.set_alt_speed_enabled(True) - self._tm_client.api.update() + await self.hass.async_add_executor_job( + self.coordinator.set_alt_speed_enabled, True + ) + await self.coordinator.async_request_refresh() - def turn_off(self, **kwargs: Any) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if self.type == "on_off": _LOGGING.debug("Stopping all torrents") - self._tm_client.api.stop_torrents() + await self.hass.async_add_executor_job(self.coordinator.stop_torrents) if self.type == "turtle_mode": _LOGGING.debug("Turning Turtle Mode of Transmission off") - self._tm_client.api.set_alt_speed_enabled(False) - self._tm_client.api.update() - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - self.unsub_update = async_dispatcher_connect( - self.hass, - self._tm_client.api.signal_update, - self._schedule_immediate_update, - ) - - @callback - def _schedule_immediate_update(self) -> None: - self.async_schedule_update_ha_state(True) - - async def will_remove_from_hass(self) -> None: - """Unsubscribe from update dispatcher.""" - if self.unsub_update: - self.unsub_update() - self.unsub_update = None - - def update(self) -> None: - """Get the latest data from Transmission and updates the state.""" - active = None - if self.type == "on_off": - self._data = self._tm_client.api.data - if self._data: - active = self._data.active_torrent_count > 0 - - elif self.type == "turtle_mode": - active = self._tm_client.api.get_alt_speed_enabled() - - if active is None: - return - - self._state = STATE_ON if active else STATE_OFF + await self.hass.async_add_executor_job( + self.coordinator.set_alt_speed_enabled, False + ) + await self.coordinator.async_request_refresh() From 6d2fbeb556aa8de6ac2e12a3b1fab67558811ff5 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 12 Oct 2023 21:06:09 +0200 Subject: [PATCH 406/968] Migrate ViCare to has_entity_name (#101895) * set has_entity_name * remove sensor prefix * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vicare/binary_sensor.py | 15 +++++++-------- homeassistant/components/vicare/button.py | 5 ++--- homeassistant/components/vicare/climate.py | 5 ++--- homeassistant/components/vicare/entity.py | 2 ++ homeassistant/components/vicare/sensor.py | 14 ++++++-------- homeassistant/components/vicare/water_heater.py | 5 ++--- 6 files changed, 21 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index ad8727a12ef..0a54b472c07 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -117,7 +117,7 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, name, entities, sensor_descriptions, iterables, config_entry + hass, entities, sensor_descriptions, iterables, config_entry ): """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: @@ -127,7 +127,7 @@ async def _entities_from_descriptions( suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}{suffix}", + f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -142,7 +142,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare binary sensor devices.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] @@ -150,7 +149,7 @@ async def async_setup_entry( for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -160,21 +159,21 @@ async def async_setup_entry( try: await _entities_from_descriptions( - hass, name, entities, CIRCUIT_SENSORS, api.circuits, config_entry + hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No circuits found") try: await _entities_from_descriptions( - hass, name, entities, BURNER_SENSORS, api.burners, config_entry + hass, entities, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, entities, COMPRESSOR_SENSORS, api.compressors, config_entry + hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index e855b274466..3a143d411c2 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixinWithSet -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -73,7 +73,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare button entities.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] @@ -81,7 +80,7 @@ async def async_setup_entry( for description in BUTTON_DESCRIPTIONS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index bca0b96c726..5ae642ceacd 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -35,7 +35,7 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -105,7 +105,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - name = VICARE_NAME entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] circuits = await hass.async_add_executor_job(_get_circuits, api) @@ -116,7 +115,7 @@ async def async_setup_entry( suffix = f" {circuit.id}" entity = ViCareClimate( - f"{name} Heating{suffix}", + f"Heating{suffix}", api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index 90779bb26d1..e3313446812 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -8,6 +8,8 @@ from .const import DOMAIN class ViCareEntity(Entity): """Base class for ViCare entities.""" + _attr_has_entity_name = True + def __init__(self, device_config) -> None: """Initialize the entity.""" diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 6d0fd2423c4..1810192d0ba 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -39,7 +39,6 @@ from .const import ( VICARE_CUBIC_METER, VICARE_DEVICE_CONFIG, VICARE_KWH, - VICARE_NAME, VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) from .entity import ViCareEntity @@ -596,7 +595,7 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, name, entities, sensor_descriptions, iterables, config_entry + hass, entities, sensor_descriptions, iterables, config_entry ): """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: @@ -606,7 +605,7 @@ async def _entities_from_descriptions( suffix = f" {current.id}" entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}{suffix}", + f"{description.name}{suffix}", current, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -621,14 +620,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the ViCare sensor devices.""" - name = VICARE_NAME api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] entities = [] for description in GLOBAL_SENSORS: entity = await hass.async_add_executor_job( _build_entity, - f"{name} {description.name}", + description.name, api, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], description, @@ -638,21 +636,21 @@ async def async_setup_entry( try: await _entities_from_descriptions( - hass, name, entities, CIRCUIT_SENSORS, api.circuits, config_entry + hass, entities, CIRCUIT_SENSORS, api.circuits, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No circuits found") try: await _entities_from_descriptions( - hass, name, entities, BURNER_SENSORS, api.burners, config_entry + hass, entities, BURNER_SENSORS, api.burners, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No burners found") try: await _entities_from_descriptions( - hass, name, entities, COMPRESSOR_SENSORS, api.compressors, config_entry + hass, entities, COMPRESSOR_SENSORS, api.compressors, config_entry ) except PyViCareNotSupportedFeatureError: _LOGGER.info("No compressors found") diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 2e312d54d7d..59e0bb522f4 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -19,7 +19,7 @@ from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemper from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ViCare climate platform.""" - name = VICARE_NAME entities = [] api = hass.data[DOMAIN][config_entry.entry_id][VICARE_API] circuits = await hass.async_add_executor_job(_get_circuits, api) @@ -80,7 +79,7 @@ async def async_setup_entry( suffix = f" {circuit.id}" entity = ViCareWater( - f"{name} Water{suffix}", + f"Water{suffix}", api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], From 8b134f26a939d55842867d3ca9262a5ccf293e20 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 12 Oct 2023 22:13:33 +0200 Subject: [PATCH 407/968] Fix transmission Coordinator typing (#101903) --- homeassistant/components/transmission/sensor.py | 5 +++-- homeassistant/components/transmission/switch.py | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 0b949e73f47..0a0f0dae383 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -4,7 +4,6 @@ from __future__ import annotations from contextlib import suppress from typing import Any -from transmission_rpc.session import SessionStats from transmission_rpc.torrent import Torrent from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -92,7 +91,9 @@ async def async_setup_entry( async_add_entities(dev, True) -class TransmissionSensor(CoordinatorEntity[SessionStats], SensorEntity): +class TransmissionSensor( + CoordinatorEntity[TransmissionDataUpdateCoordinator], SensorEntity +): """A base class for all Transmission sensors.""" _attr_has_entity_name = True diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 253ceb558b9..3e7573b1951 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -3,8 +3,6 @@ from collections.abc import Callable import logging from typing import Any -from transmission_rpc.session import SessionStats - from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME @@ -38,7 +36,9 @@ async def async_setup_entry( async_add_entities(dev, True) -class TransmissionSwitch(CoordinatorEntity[SessionStats], SwitchEntity): +class TransmissionSwitch( + CoordinatorEntity[TransmissionDataUpdateCoordinator], SwitchEntity +): """Representation of a Transmission switch.""" _attr_has_entity_name = True From ff5504f55f9e5418df66836dabe2296900c637a0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 12 Oct 2023 22:20:39 +0200 Subject: [PATCH 408/968] Add strict typing for transmission (#101904) --- .strict-typing | 1 + homeassistant/components/transmission/const.py | 8 +++++++- homeassistant/components/transmission/coordinator.py | 2 +- mypy.ini | 10 ++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 783395ff926..4aa0a44c96d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -345,6 +345,7 @@ homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* +homeassistant.components.transmission.* homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* diff --git a/homeassistant/components/transmission/const.py b/homeassistant/components/transmission/const.py index cb31d5a5aac..77d2baf7213 100644 --- a/homeassistant/components/transmission/const.py +++ b/homeassistant/components/transmission/const.py @@ -1,4 +1,10 @@ """Constants for the Transmission Bittorent Client component.""" +from __future__ import annotations + +from collections.abc import Callable + +from transmission_rpc import Torrent + DOMAIN = "transmission" SWITCH_TYPES = {"on_off": "Switch", "turtle_mode": "Turtle mode"} @@ -8,7 +14,7 @@ ORDER_OLDEST_FIRST = "oldest_first" ORDER_BEST_RATIO_FIRST = "best_ratio_first" ORDER_WORST_RATIO_FIRST = "worst_ratio_first" -SUPPORTED_ORDER_MODES = { +SUPPORTED_ORDER_MODES: dict[str, Callable[[list[Torrent]], list[Torrent]]] = { ORDER_NEWEST_FIRST: lambda torrents: sorted( torrents, key=lambda t: t.date_added, reverse=True ), diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 5fce7cae53d..a9cfc93eea0 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -163,4 +163,4 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): if self._session is None: return None - return self._session.alt_speed_enabled + return self._session.alt_speed_enabled # type: ignore[no-any-return] diff --git a/mypy.ini b/mypy.ini index 0bc95fa5970..40d57a6b430 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3212,6 +3212,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.transmission.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.trend.*] check_untyped_defs = true disallow_incomplete_defs = true From dc18a7f1fb3b56b378cbee265937adf87c265476 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 11:48:00 -1000 Subject: [PATCH 409/968] Bump aioesphomeapi to 17.1.4 (#101897) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8169eeb70e3..8488dcc4127 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.0.1", + "aioesphomeapi==17.1.4", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index d0b6b7ab6bb..3b994f25c8e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.1 +aioesphomeapi==17.1.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 889d6de2da9..f6d80fa8b7e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.0.1 +aioesphomeapi==17.1.4 # homeassistant.components.flo aioflo==2021.11.0 From 03210d7f8130e16c0f6c037550847f284e9afee5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 12:06:47 -1000 Subject: [PATCH 410/968] Fix implicit name in airzone_cloud (#101907) --- homeassistant/components/airzone_cloud/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index a86440bad20..89e528a0fbf 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -129,6 +129,7 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): """Define an Airzone Cloud climate.""" _attr_has_entity_name = True + _attr_name = None _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS From d712a29052863b8280b309dd8e8036c092534412 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 13 Oct 2023 07:34:31 +0200 Subject: [PATCH 411/968] Split Withings coordinators (#101766) * Subscribe to Withings webhooks outside of coordinator * Subscribe to Withings webhooks outside of coordinator * Split Withings coordinator * Split Withings coordinator * Update homeassistant/components/withings/sensor.py * Fix merge * Rename MEASUREMENT_COORDINATOR * Update homeassistant/components/withings/__init__.py Co-authored-by: Martin Hjelmare * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Fix feedback --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/withings/__init__.py | 45 ++++++-- .../components/withings/binary_sensor.py | 11 +- homeassistant/components/withings/const.py | 4 + .../components/withings/coordinator.py | 100 +++++++++++++----- homeassistant/components/withings/sensor.py | 41 ++++++- tests/components/withings/test_init.py | 5 +- tests/components/withings/test_sensor.py | 4 +- 7 files changed, 163 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 225ff5603c4..a17dffd22e8 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -43,8 +43,22 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from .api import ConfigEntryWithingsApi -from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER -from .coordinator import WithingsDataUpdateCoordinator +from .const import ( + BED_PRESENCE_COORDINATOR, + CONF_PROFILES, + CONF_USE_WEBHOOK, + DEFAULT_TITLE, + DOMAIN, + LOGGER, + MEASUREMENT_COORDINATOR, + SLEEP_COORDINATOR, +) +from .coordinator import ( + WithingsBedPresenceDataUpdateCoordinator, + WithingsDataUpdateCoordinator, + WithingsMeasurementDataUpdateCoordinator, + WithingsSleepDataUpdateCoordinator, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -128,11 +142,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry ), ) - coordinator = WithingsDataUpdateCoordinator(hass, client) + coordinators: dict[str, WithingsDataUpdateCoordinator] = { + MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client), + SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client), + BED_PRESENCE_COORDINATOR: WithingsBedPresenceDataUpdateCoordinator( + hass, client + ), + } - await coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values(): + await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators async def unregister_webhook( _: Any, @@ -140,7 +161,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await async_unsubscribe_webhooks(client) - coordinator.webhook_subscription_listener(False) + for coordinator in coordinators.values(): + coordinator.webhook_subscription_listener(False) async def register_webhook( _: Any, @@ -166,11 +188,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, webhook_name, entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinator), + get_webhook_handler(coordinators), ) await async_subscribe_webhooks(client, webhook_url) - coordinator.webhook_subscription_listener(True) + for coordinator in coordinators.values(): + coordinator.webhook_subscription_listener(True) LOGGER.debug("Register Withings webhook: %s", webhook_url) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) @@ -287,7 +310,7 @@ def json_message_response(message: str, message_code: int) -> Response: def get_webhook_handler( - coordinator: WithingsDataUpdateCoordinator, + coordinators: dict[str, WithingsDataUpdateCoordinator], ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: """Return webhook handler.""" @@ -318,7 +341,9 @@ def get_webhook_handler( except ValueError: return json_message_response("Invalid appli provided", message_code=21) - await coordinator.async_webhook_data_updated(appli) + for coordinator in coordinators.values(): + if appli in coordinator.notification_categories: + await coordinator.async_webhook_data_updated(appli) return json_message_response("Success", message_code=0) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 629114247ce..24698f90809 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -9,8 +9,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import WithingsDataUpdateCoordinator +from .const import BED_PRESENCE_COORDINATOR, DOMAIN +from .coordinator import WithingsBedPresenceDataUpdateCoordinator from .entity import WithingsEntity @@ -20,7 +20,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator: WithingsBedPresenceDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][BED_PRESENCE_COORDINATOR] entities = [WithingsBinarySensor(coordinator)] @@ -33,8 +35,9 @@ class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): _attr_icon = "mdi:bed" _attr_translation_key = "in_bed" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY + coordinator: WithingsBedPresenceDataUpdateCoordinator - def __init__(self, coordinator: WithingsDataUpdateCoordinator) -> None: + def __init__(self, coordinator: WithingsBedPresenceDataUpdateCoordinator) -> None: """Initialize binary sensor.""" super().__init__(coordinator, "in_bed") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 545c7bfcb26..bc3e26765a4 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -14,6 +14,10 @@ LOG_NAMESPACE = "homeassistant.components.withings" PROFILE = "profile" PUSH_HANDLER = "push_handler" +MEASUREMENT_COORDINATOR = "measurement_coordinator" +SLEEP_COORDINATOR = "sleep_coordinator" +BED_PRESENCE_COORDINATOR = "bed_presence_coordinator" + LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 2ec2804814b..f5963ad6ebf 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,7 +1,8 @@ """Withings coordinator.""" +from abc import abstractmethod from collections.abc import Callable from datetime import timedelta -from typing import Any +from typing import Any, TypeVar from withings_api.common import ( AuthFailedException, @@ -66,40 +67,66 @@ WITHINGS_MEASURE_TYPE_MAP: dict[ NotifyAppli.BED_IN: Measurement.IN_BED, } +_T = TypeVar("_T") + UPDATE_INTERVAL = timedelta(minutes=10) -class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any]]): +class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): """Base coordinator.""" - in_bed: bool | None = None config_entry: ConfigEntry + _default_update_interval: timedelta | None = UPDATE_INTERVAL def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: """Initialize the Withings data coordinator.""" - super().__init__(hass, LOGGER, name="Withings", update_interval=UPDATE_INTERVAL) + super().__init__( + hass, LOGGER, name="Withings", update_interval=self._default_update_interval + ) self._client = client + self.notification_categories: set[NotifyAppli] = set() def webhook_subscription_listener(self, connected: bool) -> None: """Call when webhook status changed.""" if connected: self.update_interval = None else: - self.update_interval = UPDATE_INTERVAL + self.update_interval = self._default_update_interval - async def _async_update_data(self) -> dict[Measurement, Any]: + async def async_webhook_data_updated( + self, notification_category: NotifyAppli + ) -> None: + """Update data when webhook is called.""" + LOGGER.debug("Withings webhook triggered for %s", notification_category) + await self.async_request_refresh() + + async def _async_update_data(self) -> _T: try: - measurements = await self._get_measurements() - sleep_summary = await self._get_sleep_summary() + return await self._internal_update_data() except (UnauthorizedException, AuthFailedException) as exc: raise ConfigEntryAuthFailed from exc - return { - **measurements, - **sleep_summary, + + @abstractmethod + async def _internal_update_data(self) -> _T: + """Update coordinator data.""" + + +class WithingsMeasurementDataUpdateCoordinator( + WithingsDataUpdateCoordinator[dict[Measurement, Any]] +): + """Withings measurement coordinator.""" + + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotifyAppli.WEIGHT, + NotifyAppli.ACTIVITY, + NotifyAppli.CIRCULATORY, } - async def _get_measurements(self) -> dict[Measurement, Any]: - LOGGER.debug("Updating withings measures") + async def _internal_update_data(self) -> dict[Measurement, Any]: + """Retrieve measurement data.""" now = dt_util.utcnow() startdate = now - timedelta(days=7) @@ -125,7 +152,21 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] if measure.type in WITHINGS_MEASURE_TYPE_MAP } - async def _get_sleep_summary(self) -> dict[Measurement, Any]: + +class WithingsSleepDataUpdateCoordinator( + WithingsDataUpdateCoordinator[dict[Measurement, Any]] +): + """Withings sleep coordinator.""" + + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotifyAppli.SLEEP, + } + + async def _internal_update_data(self) -> dict[Measurement, Any]: + """Retrieve sleep data.""" now = dt_util.now() yesterday = now - timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) @@ -202,18 +243,27 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[dict[Measurement, Any] for field, value in values.items() } + +class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): + """Withings bed presence coordinator.""" + + in_bed: bool | None = None + _default_update_interval = None + + def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotifyAppli.BED_IN, + NotifyAppli.BED_OUT, + } + async def async_webhook_data_updated( self, notification_category: NotifyAppli ) -> None: - """Update data when webhook is called.""" - LOGGER.debug("Withings webhook triggered") - if notification_category in { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.SLEEP, - }: - await self.async_request_refresh() + """Only set new in bed value instead of refresh.""" + self.in_bed = notification_category == NotifyAppli.BED_IN + self.async_update_listeners() - elif notification_category in {NotifyAppli.BED_IN, NotifyAppli.BED_OUT}: - self.in_bed = notification_category == NotifyAppli.BED_IN - self.async_update_listeners() + async def _internal_update_data(self) -> None: + """Update coordinator data.""" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index bb615dfb7ca..200ad7aedd5 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -25,14 +25,20 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN, + MEASUREMENT_COORDINATOR, SCORE_POINTS, + SLEEP_COORDINATOR, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, UOM_FREQUENCY, UOM_MMHG, Measurement, ) -from .coordinator import WithingsDataUpdateCoordinator +from .coordinator import ( + WithingsDataUpdateCoordinator, + WithingsMeasurementDataUpdateCoordinator, + WithingsSleepDataUpdateCoordinator, +) from .entity import WithingsEntity @@ -51,7 +57,7 @@ class WithingsSensorEntityDescription( """Immutable class for describing withings data.""" -SENSORS = [ +MEASUREMENT_SENSORS = [ WithingsSensorEntityDescription( key=Measurement.WEIGHT_KG.value, measurement=Measurement.WEIGHT_KG, @@ -193,6 +199,8 @@ SENSORS = [ device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, ), +] +SLEEP_SENSORS = [ WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, @@ -369,9 +377,22 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator: WithingsDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[ + DOMAIN + ][entry.entry_id][MEASUREMENT_COORDINATOR] + entities: list[SensorEntity] = [] + entities.extend( + WithingsMeasurementSensor(measurement_coordinator, attribute) + for attribute in MEASUREMENT_SENSORS + ) + sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][SLEEP_COORDINATOR] - async_add_entities(WithingsSensor(coordinator, attribute) for attribute in SENSORS) + entities.extend( + WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS + ) + async_add_entities(entities) class WithingsSensor(WithingsEntity, SensorEntity): @@ -400,3 +421,15 @@ class WithingsSensor(WithingsEntity, SensorEntity): super().available and self.entity_description.measurement in self.coordinator.data ) + + +class WithingsMeasurementSensor(WithingsSensor): + """Implementation of a Withings measurement sensor.""" + + coordinator: WithingsMeasurementDataUpdateCoordinator + + +class WithingsSleepSensor(WithingsSensor): + """Implementation of a Withings sleep sensor.""" + + coordinator: WithingsSleepDataUpdateCoordinator diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index ab83bbcfb36..a3509c8547b 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -223,13 +223,14 @@ async def test_triggering_reauth( withings: AsyncMock, polling_config_entry: MockConfigEntry, error: Exception, + freezer: FrozenDateTimeFactory, ) -> None: """Test triggering reauth.""" await setup_integration(hass, polling_config_entry, False) withings.async_measure_get_meas.side_effect = error - future = dt_util.utcnow() + timedelta(minutes=10) - async_fire_time_changed(hass, future) + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index febf0a1a5d9..f5d15e5dea9 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -8,7 +8,7 @@ from syrupy import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.withings.const import DOMAIN -from homeassistant.components.withings.sensor import SENSORS +from homeassistant.components.withings.sensor import MEASUREMENT_SENSORS, SLEEP_SENSORS from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -42,7 +42,7 @@ async def test_all_entities( """Test all entities.""" await setup_integration(hass, polling_config_entry) - for sensor in SENSORS: + for sensor in MEASUREMENT_SENSORS + SLEEP_SENSORS: entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot From f330bc0f97da92ca6c6bf893dffeac2ddc22ab5d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 13 Oct 2023 10:23:32 +0200 Subject: [PATCH 412/968] Uncancel task when swallowing CancelledError (#101884) --- homeassistant/components/reolink/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 5cfb2ceecb7..fd62f8451fb 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -95,6 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b try: return await host.api.check_new_firmware() except (ReolinkError, asyncio.exceptions.CancelledError) as err: + task = asyncio.current_task() + if task is not None: + task.uncancel() if starting: _LOGGER.debug( "Error checking Reolink firmware update at startup " From fecaf9aa60265de80b5b35a5522c276f145d151d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 22:39:44 -1000 Subject: [PATCH 413/968] Bump zeroconf to 0.116.0 (#101915) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 4c76a0c46ef..a8462e62632 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.115.2"] + "requirements": ["zeroconf==0.116.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5e657dde403..d9f493ce723 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -52,7 +52,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.115.2 +zeroconf==0.116.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 3b994f25c8e..c714f54a180 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2787,7 +2787,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.115.2 +zeroconf==0.116.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6d80fa8b7e..19f321b3e36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2081,7 +2081,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.115.2 +zeroconf==0.116.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 5f91bdcfc5b11d184d4f30cd6b9cfaf36314dd85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 23:05:25 -1000 Subject: [PATCH 414/968] Fix implicit device name in wiz switch (#101914) --- homeassistant/components/wiz/switch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/wiz/switch.py b/homeassistant/components/wiz/switch.py index ffe75910b40..1bebaba7579 100644 --- a/homeassistant/components/wiz/switch.py +++ b/homeassistant/components/wiz/switch.py @@ -30,6 +30,8 @@ async def async_setup_entry( class WizSocketEntity(WizToggleEntity, SwitchEntity): """Representation of a WiZ socket.""" + _attr_name = None + def __init__(self, wiz_data: WizData, name: str) -> None: """Initialize a WiZ socket.""" super().__init__(wiz_data, name) From 43753b841ff957d3eb9eb3b9b3fe8a4740df0d53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Oct 2023 23:05:52 -1000 Subject: [PATCH 415/968] Bump aioesphomeapi to 17.1.5 (#101916) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8488dcc4127..82567d7310b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.1.4", + "aioesphomeapi==17.1.5", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index c714f54a180..7fc8f713e17 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.1.4 +aioesphomeapi==17.1.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19f321b3e36..f88ca559a4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.1.4 +aioesphomeapi==17.1.5 # homeassistant.components.flo aioflo==2021.11.0 From 53e97fee0e46fdfc5df5f3d617f3ae827a08570f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 13 Oct 2023 13:35:34 +0200 Subject: [PATCH 416/968] Improve Withings test fixtures (#101931) --- .../withings/fixtures/get_meas.json | 51 ++-- .../withings/fixtures/get_sleep.json | 233 ++++++++++++++---- .../withings/snapshots/test_sensor.ambr | 34 +-- 3 files changed, 237 insertions(+), 81 deletions(-) diff --git a/tests/components/withings/fixtures/get_meas.json b/tests/components/withings/fixtures/get_meas.json index a7a2c09156c..1776ba8ff8a 100644 --- a/tests/components/withings/fixtures/get_meas.json +++ b/tests/components/withings/fixtures/get_meas.json @@ -5,12 +5,14 @@ "offset": 0, "measuregrps": [ { - "attrib": 0, - "category": 1, - "created": 1564660800, - "date": 1564660800, - "deviceid": "DEV_ID", "grpid": 1, + "attrib": 0, + "date": 1564660800, + "created": 1564660800, + "modified": 1564660800, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", "measures": [ { "type": 1, @@ -92,15 +94,20 @@ "unit": 0, "value": 100 } - ] + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null }, { - "attrib": 0, - "category": 1, - "created": 1564657200, - "date": 1564657200, - "deviceid": "DEV_ID", "grpid": 1, + "attrib": 0, + "date": 1564657200, + "created": 1564657200, + "modified": 1564657200, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", "measures": [ { "type": 1, @@ -182,15 +189,20 @@ "unit": 0, "value": 101 } - ] + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null }, { - "attrib": 1, - "category": 1, - "created": 1564664400, - "date": 1564664400, - "deviceid": "DEV_ID", "grpid": 1, + "attrib": 1, + "date": 1564664400, + "created": 1564664400, + "modified": 1564664400, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", "measures": [ { "type": 1, @@ -272,7 +284,10 @@ "unit": 0, "value": 102 } - ] + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null } ] } diff --git a/tests/components/withings/fixtures/get_sleep.json b/tests/components/withings/fixtures/get_sleep.json index fdc0e064709..29ed3df3fd3 100644 --- a/tests/components/withings/fixtures/get_sleep.json +++ b/tests/components/withings/fixtures/get_sleep.json @@ -3,58 +3,199 @@ "offset": 0, "series": [ { - "timezone": "UTC", + "id": 2081804182, + "timezone": "Europe/Paris", "model": 32, - "startdate": 1548979200, - "enddate": 1548979200, - "date": 1548979200, - "modified": 12345, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618691453, + "enddate": 1618713173, + "date": "2021-04-18", "data": { - "breathing_disturbances_intensity": 110, - "deepsleepduration": 111, - "durationtosleep": 112, - "durationtowakeup": 113, - "hr_average": 114, - "hr_max": 115, - "hr_min": 116, - "lightsleepduration": 117, - "remsleepduration": 118, - "rr_average": 119, - "rr_max": 120, - "rr_min": 121, - "sleep_score": 122, - "snoring": 123, - "snoringepisodecount": 124, - "wakeupcount": 125, - "wakeupduration": 126 - } + "wakeupduration": 3060, + "wakeupcount": 1, + "durationtosleep": 540, + "remsleepduration": 2400, + "durationtowakeup": 1140, + "total_sleep_time": 18660, + "sleep_efficiency": 0.86, + "sleep_latency": 540, + "wakeup_latency": 1140, + "waso": 1380, + "nb_rem_episodes": 1, + "out_of_bed_count": 0, + "lightsleepduration": 10440, + "deepsleepduration": 5820, + "hr_average": 103, + "hr_min": 70, + "hr_max": 120, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 9, + "snoring": 1080, + "snoringepisodecount": 18, + "sleep_score": 37, + "apnea_hypopnea_index": 9 + }, + "created": 1620237476, + "modified": 1620237476 }, { - "timezone": "UTC", + "id": 2081804265, + "timezone": "Europe/Paris", "model": 32, - "startdate": 1548979200, - "enddate": 1548979200, - "date": 1548979200, - "modified": 12345, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618605055, + "enddate": 1618636975, + "date": "2021-04-17", "data": { - "breathing_disturbances_intensity": 210, - "deepsleepduration": 211, - "durationtosleep": 212, - "durationtowakeup": 213, - "hr_average": 214, - "hr_max": 215, - "hr_min": 216, - "lightsleepduration": 217, - "remsleepduration": 218, - "rr_average": 219, - "rr_max": 220, - "rr_min": 221, - "sleep_score": 222, - "snoring": 223, - "snoringepisodecount": 224, - "wakeupcount": 225, - "wakeupduration": 226 - } + "wakeupduration": 2520, + "wakeupcount": 3, + "durationtosleep": 900, + "remsleepduration": 6840, + "durationtowakeup": 420, + "total_sleep_time": 26880, + "sleep_efficiency": 0.91, + "sleep_latency": 900, + "wakeup_latency": 420, + "waso": 1200, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 12840, + "deepsleepduration": 7200, + "hr_average": 85, + "hr_min": 50, + "hr_max": 120, + "rr_average": 16, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 14, + "snoring": 1140, + "snoringepisodecount": 19, + "sleep_score": 90, + "apnea_hypopnea_index": 14 + }, + "created": 1620237480, + "modified": 1620237479 + }, + { + "id": 2081804358, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618518658, + "enddate": 1618548058, + "date": "2021-04-16", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237484, + "modified": 1620237484 + }, + { + "id": 2081804405, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618432203, + "enddate": 1618453143, + "date": "2021-04-15", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237486, + "modified": 1620237486 + }, + { + "id": 2081804490, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618345805, + "enddate": 1618373504, + "date": "2021-04-14", + "data": { + "wakeupduration": 3600, + "wakeupcount": 2, + "durationtosleep": 780, + "remsleepduration": 3960, + "durationtowakeup": 300, + "total_sleep_time": 22680, + "sleep_efficiency": 0.86, + "sleep_latency": 780, + "wakeup_latency": 300, + "waso": 3939, + "nb_rem_episodes": 4, + "out_of_bed_count": 3, + "lightsleepduration": 12960, + "deepsleepduration": 5760, + "hr_average": 98, + "hr_min": 70, + "hr_max": 120, + "rr_average": 13, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 29, + "snoring": 960, + "snoringepisodecount": 16, + "sleep_score": 62, + "apnea_hypopnea_index": 29 + }, + "created": 1620237490, + "modified": 1620237489 } ] } diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 9733880b03a..833ac4148a0 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -127,7 +127,7 @@ 'entity_id': 'sensor.henk_breathing_disturbances_intensity', 'last_changed': , 'last_updated': , - 'state': '160.0', + 'state': '10.0', }) # --- # name: test_all_entities.17 @@ -143,7 +143,7 @@ 'entity_id': 'sensor.henk_deep_sleep', 'last_changed': , 'last_updated': , - 'state': '322', + 'state': '26220', }) # --- # name: test_all_entities.18 @@ -159,7 +159,7 @@ 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_updated': , - 'state': '162.0', + 'state': '780.0', }) # --- # name: test_all_entities.19 @@ -175,7 +175,7 @@ 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_updated': , - 'state': '163.0', + 'state': '996.0', }) # --- # name: test_all_entities.2 @@ -205,7 +205,7 @@ 'entity_id': 'sensor.henk_average_heart_rate', 'last_changed': , 'last_updated': , - 'state': '164.0', + 'state': '83.2', }) # --- # name: test_all_entities.21 @@ -220,7 +220,7 @@ 'entity_id': 'sensor.henk_maximum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '165.0', + 'state': '108.4', }) # --- # name: test_all_entities.22 @@ -235,7 +235,7 @@ 'entity_id': 'sensor.henk_minimum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '166.0', + 'state': '58.0', }) # --- # name: test_all_entities.23 @@ -251,7 +251,7 @@ 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_updated': , - 'state': '334', + 'state': '58440', }) # --- # name: test_all_entities.24 @@ -267,7 +267,7 @@ 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_updated': , - 'state': '336', + 'state': '17280', }) # --- # name: test_all_entities.25 @@ -281,7 +281,7 @@ 'entity_id': 'sensor.henk_average_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '169.0', + 'state': '14.2', }) # --- # name: test_all_entities.26 @@ -295,7 +295,7 @@ 'entity_id': 'sensor.henk_maximum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '170.0', + 'state': '20.0', }) # --- # name: test_all_entities.27 @@ -309,7 +309,7 @@ 'entity_id': 'sensor.henk_minimum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '171.0', + 'state': '10.0', }) # --- # name: test_all_entities.28 @@ -324,7 +324,7 @@ 'entity_id': 'sensor.henk_sleep_score', 'last_changed': , 'last_updated': , - 'state': '222', + 'state': '90', }) # --- # name: test_all_entities.29 @@ -337,7 +337,7 @@ 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_updated': , - 'state': '173.0', + 'state': '1044.0', }) # --- # name: test_all_entities.3 @@ -365,7 +365,7 @@ 'entity_id': 'sensor.henk_snoring_episode_count', 'last_changed': , 'last_updated': , - 'state': '348', + 'state': '87', }) # --- # name: test_all_entities.31 @@ -380,7 +380,7 @@ 'entity_id': 'sensor.henk_wakeup_count', 'last_changed': , 'last_updated': , - 'state': '350', + 'state': '8', }) # --- # name: test_all_entities.32 @@ -396,7 +396,7 @@ 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_updated': , - 'state': '176.0', + 'state': '3468.0', }) # --- # name: test_all_entities.33 From 4e9ec820828e132713e17e0d4cf3adad9ef53f0d Mon Sep 17 00:00:00 2001 From: Vadym Holoveichuk Date: Fri, 13 Oct 2023 15:09:35 +0300 Subject: [PATCH 417/968] Fix Setpoint in Matter climate platform (#101929) fix matter max setpoint --- homeassistant/components/matter/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6da88533edc..44e5d30fec4 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -250,9 +250,9 @@ class MatterClimate(MatterEntity, ClimateEntity): self._attr_min_temp = DEFAULT_MIN_TEMP # update max_temp if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT_COOL): - attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit - else: attribute = clusters.Thermostat.Attributes.AbsMaxCoolSetpointLimit + else: + attribute = clusters.Thermostat.Attributes.AbsMaxHeatSetpointLimit if (value := self._get_temperature_in_degrees(attribute)) is not None: self._attr_max_temp = value else: From 2dfc8b9d7fe5ecb5109dec7d5bfa2ad45395935c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 02:11:17 -1000 Subject: [PATCH 418/968] Avoid conversion of timestamps in jwt auth (#101856) --- homeassistant/auth/__init__.py | 7 ++++--- tests/auth/test_init.py | 13 +++++++++---- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 9a537174270..2707f8b6899 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -5,6 +5,7 @@ import asyncio from collections import OrderedDict from collections.abc import Mapping from datetime import timedelta +import time from typing import Any, cast import jwt @@ -12,7 +13,6 @@ import jwt from homeassistant import data_entry_flow from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.util import dt as dt_util from . import auth_store, jwt_wrapper, models from .const import ACCESS_TOKEN_EXPIRATION, GROUP_ID_ADMIN @@ -505,12 +505,13 @@ class AuthManager: self._store.async_log_refresh_token_usage(refresh_token, remote_ip) - now = dt_util.utcnow() + now = int(time.time()) + expire_seconds = int(refresh_token.access_token_expiration.total_seconds()) return jwt.encode( { "iss": refresh_token.id, "iat": now, - "exp": now + refresh_token.access_token_expiration, + "exp": now + expire_seconds, }, refresh_token.jwt_key, algorithm="HS256", diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 3cead230b1b..ef7beab488b 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -1,5 +1,6 @@ """Tests for the Home Assistant auth module.""" from datetime import timedelta +import time from typing import Any from unittest.mock import patch @@ -371,11 +372,15 @@ async def test_cannot_retrieve_expired_access_token(hass: HomeAssistant) -> None access_token = manager.async_create_access_token(refresh_token) assert await manager.async_validate_access_token(access_token) is refresh_token + # We patch time directly here because we want the access token to be created with + # an expired time, but we do not want to freeze time so that jwt will compare it + # to the patched time. If we freeze time for the test it will be frozen for jwt + # as well and the token will not be expired. with patch( - "homeassistant.util.dt.utcnow", - return_value=dt_util.utcnow() - - auth_const.ACCESS_TOKEN_EXPIRATION - - timedelta(seconds=11), + "homeassistant.auth.time.time", + return_value=time.time() + - auth_const.ACCESS_TOKEN_EXPIRATION.total_seconds() + - 11, ): access_token = manager.async_create_access_token(refresh_token) From 02567d9bf674495e51cb9db1b37704dca72e0112 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Oct 2023 14:12:42 +0200 Subject: [PATCH 419/968] Revert aiohttp to 3.8.5 for Python 3.11 (#101932) --- homeassistant/components/hassio/http.py | 2 +- homeassistant/helpers/aiohttp_client.py | 9 ++++++--- homeassistant/package_constraints.txt | 3 ++- pyproject.toml | 3 ++- requirements.txt | 3 ++- tests/components/generic/test_camera.py | 16 ++++++++++++---- tests/util/test_aiohttp.py | 23 ++++++++++++++++++----- 7 files changed, 43 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 84b49af11c2..0d23a953128 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -157,7 +157,7 @@ class HassIOView(HomeAssistantView): if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary # pylint: disable-next=protected-access - headers[CONTENT_TYPE] = request._stored_content_type # type: ignore[assignment] + headers[CONTENT_TYPE] = request._stored_content_type try: client = await self._websession.request( diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 1948d3bca95..20351efff53 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from contextlib import suppress +from ssl import SSLContext import sys from types import MappingProxyType from typing import TYPE_CHECKING, Any, cast @@ -65,8 +66,10 @@ async def _noop_wait(*args: Any, **kwargs: Any) -> None: return -# pylint: disable-next=protected-access -web.BaseSite._wait = _noop_wait # type: ignore[method-assign] +# TODO: Remove version check with aiohttp 3.9.0 # pylint: disable=fixme +if sys.version_info >= (3, 12): + # pylint: disable-next=protected-access + web.BaseSite._wait = _noop_wait # type: ignore[method-assign] class HassClientResponse(aiohttp.ClientResponse): @@ -286,7 +289,7 @@ def _async_get_connector( return cast(aiohttp.BaseConnector, hass.data[key]) if verify_ssl: - ssl_context = ssl_util.get_default_context() + ssl_context: bool | SSLContext = ssl_util.get_default_context() else: ssl_context = ssl_util.get_default_no_verify_context() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d9f493ce723..2a5ed1274b2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,5 +1,6 @@ aiodiscover==1.5.1 -aiohttp==3.9.0b0 +aiohttp==3.8.5;python_version<'3.12' +aiohttp==3.9.0b0;python_version>='3.12' aiohttp_cors==0.7.0 astral==2.2 async-upnp-client==0.36.1 diff --git a/pyproject.toml b/pyproject.toml index 508b2c06b9e..5cc122850ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,8 @@ classifiers = [ ] requires-python = ">=3.11.0" dependencies = [ - "aiohttp==3.9.0b0", + "aiohttp==3.9.0b0;python_version>='3.12'", + "aiohttp==3.8.5;python_version<'3.12'", "astral==2.2", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", diff --git a/requirements.txt b/requirements.txt index 3d64dbe69ff..e6359a6bfd1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ -c homeassistant/package_constraints.txt # Home Assistant Core -aiohttp==3.9.0b0 +aiohttp==3.9.0b0;python_version>='3.12' +aiohttp==3.8.5;python_version<'3.12' astral==2.2 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 8bfd0a66dd5..aecfcbc29c1 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -1,6 +1,7 @@ """The tests for generic camera component.""" import asyncio from http import HTTPStatus +import sys from unittest.mock import patch import aiohttp @@ -163,10 +164,17 @@ async def test_limit_refetch( hass.states.async_set("sensor.temp", "5") - with pytest.raises(aiohttp.ServerTimeoutError), patch( - "asyncio.timeout", side_effect=asyncio.TimeoutError() - ): - resp = await client.get("/api/camera_proxy/camera.config_test") + # TODO: Remove version check with aiohttp 3.9.0 + if sys.version_info >= (3, 12): + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "asyncio.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") + else: + with pytest.raises(aiohttp.ServerTimeoutError), patch( + "async_timeout.timeout", side_effect=asyncio.TimeoutError() + ): + resp = await client.get("/api/camera_proxy/camera.config_test") assert respx.calls.call_count == 1 assert resp.status == HTTPStatus.OK diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py index 496e6373ba5..bfdc3c3e949 100644 --- a/tests/util/test_aiohttp.py +++ b/tests/util/test_aiohttp.py @@ -1,4 +1,6 @@ """Test aiohttp request helper.""" +import sys + from aiohttp import web from homeassistant.util import aiohttp @@ -48,11 +50,22 @@ def test_serialize_text() -> None: def test_serialize_body_str() -> None: """Test serializing a response with a str as body.""" response = web.Response(status=201, body="Hello") - assert aiohttp.serialize_response(response) == { - "status": 201, - "body": "Hello", - "headers": {"Content-Type": "text/plain; charset=utf-8"}, - } + # TODO: Remove version check with aiohttp 3.9.0 + if sys.version_info >= (3, 12): + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": {"Content-Type": "text/plain; charset=utf-8"}, + } + else: + assert aiohttp.serialize_response(response) == { + "status": 201, + "body": "Hello", + "headers": { + "Content-Length": "5", + "Content-Type": "text/plain; charset=utf-8", + }, + } def test_serialize_body_None() -> None: From 2609394b9fd3a2e138197178bb4e488a7a44011f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 13 Oct 2023 15:28:58 +0200 Subject: [PATCH 420/968] Add device info to Launch Library (#98767) --- .../components/launch_library/sensor.py | 27 +++++++++++-------- .../components/launch_library/strings.json | 25 +++++++++++++++++ 2 files changed, 41 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 85183a2d616..5dab7da56ed 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -17,6 +17,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, PERCENTAGE from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -49,7 +50,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="next_launch", icon="mdi:rocket-launch", - name="Next launch", + translation_key="next_launch", value_fn=lambda nl: nl.name, attributes_fn=lambda nl: { "provider": nl.launch_service_provider.name, @@ -61,7 +62,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_time", icon="mdi:clock-outline", - name="Launch time", + translation_key="launch_time", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda nl: parse_datetime(nl.net), attributes_fn=lambda nl: { @@ -73,7 +74,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_probability", icon="mdi:dice-multiple", - name="Launch probability", + translation_key="next_launch", native_unit_of_measurement=PERCENTAGE, value_fn=lambda nl: None if nl.probability == -1 else nl.probability, attributes_fn=lambda nl: None, @@ -81,14 +82,14 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="launch_status", icon="mdi:rocket-launch", - name="Launch status", + translation_key="next_launch", value_fn=lambda nl: nl.status.name, attributes_fn=lambda nl: {"reason": nl.holdreason} if nl.inhold else None, ), LaunchLibrarySensorEntityDescription( key="launch_mission", icon="mdi:orbit", - name="Launch mission", + translation_key="launch_mission", value_fn=lambda nl: nl.mission.name, attributes_fn=lambda nl: { "mission_type": nl.mission.type, @@ -99,7 +100,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="starship_launch", icon="mdi:rocket", - name="Next Starship launch", + translation_key="starship_launch", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda sl: parse_datetime(sl.net), attributes_fn=lambda sl: { @@ -112,7 +113,7 @@ SENSOR_DESCRIPTIONS: tuple[LaunchLibrarySensorEntityDescription, ...] = ( LaunchLibrarySensorEntityDescription( key="starship_event", icon="mdi:calendar", - name="Next Starship event", + translation_key="starship_event", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda se: parse_datetime(se.date), attributes_fn=lambda se: { @@ -139,7 +140,7 @@ async def async_setup_entry( coordinator=coordinator, entry_id=entry.entry_id, description=description, - name=name if description.key == "next_launch" else None, + name=name, ) for description in SENSOR_DESCRIPTIONS ) @@ -151,6 +152,7 @@ class LaunchLibrarySensor( """Representation of the next launch sensors.""" _attr_attribution = "Data provided by Launch Library." + _attr_has_entity_name = True _next_event: Launch | Event | None = None entity_description: LaunchLibrarySensorEntityDescription @@ -159,14 +161,17 @@ class LaunchLibrarySensor( coordinator: DataUpdateCoordinator[LaunchLibraryData], entry_id: str, description: LaunchLibrarySensorEntityDescription, - name: str | None = None, + name: str, ) -> None: """Initialize a Launch Library sensor.""" super().__init__(coordinator) - if name: - self._attr_name = name self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + name=name, + ) @property def native_value(self) -> datetime | str | int | None: diff --git a/homeassistant/components/launch_library/strings.json b/homeassistant/components/launch_library/strings.json index 5c6295e0f98..f3cca9fc581 100644 --- a/homeassistant/components/launch_library/strings.json +++ b/homeassistant/components/launch_library/strings.json @@ -8,5 +8,30 @@ "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } + }, + "entity": { + "sensor": { + "next_launch": { + "name": "Next launch" + }, + "launch_time": { + "name": "Launch time" + }, + "launch_probability": { + "name": "Launch probability" + }, + "launch_status": { + "name": "Launch status" + }, + "launch_mission": { + "name": "Launch mission" + }, + "starship_launch": { + "name": "Next Starship launch" + }, + "starship_event": { + "name": "Next Starship event" + } + } } } From 370e3166ee17b2d03c0a0c5c1db093a83ca94724 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Fri, 13 Oct 2023 15:40:50 +0200 Subject: [PATCH 421/968] Add diagnostics support in Minecraft Server (#101787) * Add diagnostics support * Return diagnostics dict directly * Use syrupy snapshots for assertions in diagnostics test * Use parametrize for testing diagnostics * Remove unnecessary side_effect in patch --- .../minecraft_server/diagnostics.py | 31 ++++++++++ tests/components/minecraft_server/conftest.py | 42 +++++++++++++ tests/components/minecraft_server/const.py | 4 +- .../snapshots/test_diagnostics.ambr | 57 ++++++++++++++++++ .../minecraft_server/test_diagnostics.py | 60 +++++++++++++++++++ 5 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/minecraft_server/diagnostics.py create mode 100644 tests/components/minecraft_server/conftest.py create mode 100644 tests/components/minecraft_server/snapshots/test_diagnostics.ambr create mode 100644 tests/components/minecraft_server/test_diagnostics.py diff --git a/homeassistant/components/minecraft_server/diagnostics.py b/homeassistant/components/minecraft_server/diagnostics.py new file mode 100644 index 00000000000..62e507ef09f --- /dev/null +++ b/homeassistant/components/minecraft_server/diagnostics.py @@ -0,0 +1,31 @@ +"""Diagnostics for the Minecraft Server integration.""" +from collections.abc import Iterable +from dataclasses import asdict +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ADDRESS, CONF_NAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT: Iterable[Any] = {CONF_ADDRESS, CONF_NAME, "players_list"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + return { + "config_entry": { + "version": config_entry.version, + "unique_id": config_entry.unique_id, + "entry_id": config_entry.entry_id, + }, + "config_entry_data": async_redact_data(config_entry.data, TO_REDACT), + "config_entry_options": async_redact_data(config_entry.options, TO_REDACT), + "server_data": async_redact_data(asdict(coordinator.data), TO_REDACT), + } diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py new file mode 100644 index 00000000000..8e166fbb2da --- /dev/null +++ b/tests/components/minecraft_server/conftest.py @@ -0,0 +1,42 @@ +"""Fixtures for Minecraft Server integration tests.""" +import pytest + +from homeassistant.components.minecraft_server.api import MinecraftServerType +from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN +from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE + +from .const import TEST_ADDRESS + +from tests.common import MockConfigEntry + + +@pytest.fixture +def java_mock_config_entry() -> MockConfigEntry: + """Create YouTube entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + data={ + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_ADDRESS, + CONF_TYPE: MinecraftServerType.JAVA_EDITION, + }, + version=3, + ) + + +@pytest.fixture +def bedrock_mock_config_entry() -> MockConfigEntry: + """Create YouTube entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=None, + entry_id="01234567890123456789012345678901", + data={ + CONF_NAME: DEFAULT_NAME, + CONF_ADDRESS: TEST_ADDRESS, + CONF_TYPE: MinecraftServerType.BEDROCK_EDITION, + }, + version=3, + ) diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index c579461611e..f299dd8efb8 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -16,7 +16,7 @@ TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" TEST_JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy Description"}, + "description": {"text": "Dummy MOTD"}, "version": {"name": "Dummy Version", "protocol": 123}, "players": { "online": 3, @@ -54,7 +54,7 @@ TEST_JAVA_DATA = MinecraftServerData( TEST_BEDROCK_STATUS_RESPONSE = BedrockStatusResponse( players=BedrockStatusPlayers(online=3, max=10), version=BedrockStatusVersion(brand="MCPE", name="Dummy Version", protocol=123), - motd=Motd.parse("Dummy Description", bedrock=True), + motd=Motd.parse("Dummy MOTD", bedrock=True), latency=5, gamemode="Dummy Game Mode", map_name="Dummy Map Name", diff --git a/tests/components/minecraft_server/snapshots/test_diagnostics.ambr b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..72d79795c6a --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_diagnostics.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_config_entry_diagnostics[bedrock_mock_config_entry-BedrockServer-status_response1] + dict({ + 'config_entry': dict({ + 'entry_id': '01234567890123456789012345678901', + 'unique_id': None, + 'version': 3, + }), + 'config_entry_data': dict({ + 'address': '**REDACTED**', + 'name': '**REDACTED**', + 'type': 'Bedrock Edition', + }), + 'config_entry_options': dict({ + }), + 'server_data': dict({ + 'edition': 'MCPE', + 'game_mode': 'Dummy Game Mode', + 'latency': 5, + 'map_name': 'Dummy Map Name', + 'motd': 'Dummy MOTD', + 'players_list': None, + 'players_max': 10, + 'players_online': 3, + 'protocol_version': 123, + 'version': 'Dummy Version', + }), + }) +# --- +# name: test_config_entry_diagnostics[java_mock_config_entry-JavaServer-status_response0] + dict({ + 'config_entry': dict({ + 'entry_id': '01234567890123456789012345678901', + 'unique_id': None, + 'version': 3, + }), + 'config_entry_data': dict({ + 'address': '**REDACTED**', + 'name': '**REDACTED**', + 'type': 'Java Edition', + }), + 'config_entry_options': dict({ + }), + 'server_data': dict({ + 'edition': None, + 'game_mode': None, + 'latency': 5, + 'map_name': None, + 'motd': 'Dummy MOTD', + 'players_list': '**REDACTED**', + 'players_max': 10, + 'players_online': 3, + 'protocol_version': 123, + 'version': 'Dummy Version', + }), + }) +# --- diff --git a/tests/components/minecraft_server/test_diagnostics.py b/tests/components/minecraft_server/test_diagnostics.py new file mode 100644 index 00000000000..6979325fa0c --- /dev/null +++ b/tests/components/minecraft_server/test_diagnostics.py @@ -0,0 +1,60 @@ +"""Tests for Minecraft Server diagnostics.""" +from unittest.mock import patch + +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_config_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test fetching of the config entry diagnostics.""" + + # Use 'request' fixture to access 'mock_config_entry' fixture, as it cannot be used directly in 'parametrize'. + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + # Setup mock entry. + with patch( + f"mcstatus.server.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"mcstatus.server.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Test diagnostics. + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) From 7d8ea404b334aed01ddbc2147b650fb2d08b5b4f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 13 Oct 2023 11:11:44 -0400 Subject: [PATCH 422/968] Make Basic CC Z-Wave values a light (#101438) --- .../components/zwave_js/discovery.py | 38 +++-- homeassistant/components/zwave_js/light.py | 14 +- tests/components/zwave_js/common.py | 2 +- tests/components/zwave_js/test_light.py | 143 ++++++++++++++++++ tests/components/zwave_js/test_number.py | 14 -- 5 files changed, 175 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 0a3f61fd824..46975631523 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -853,26 +853,6 @@ DISCOVERY_SCHEMAS = [ allow_multi=True, entity_registry_enabled_default=False, ), - # number for Basic CC - ZWaveDiscoverySchema( - platform=Platform.NUMBER, - hint="Basic", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.BASIC}, - type={ValueType.NUMBER}, - property={CURRENT_VALUE_PROPERTY}, - ), - required_values=[ - ZWaveValueDiscoverySchema( - command_class={ - CommandClass.BASIC, - }, - type={ValueType.NUMBER}, - property={TARGET_VALUE_PROPERTY}, - ) - ], - entity_registry_enabled_default=False, - ), # number for Indicator CC (exclude property keys 3-5) ZWaveDiscoverySchema( platform=Platform.NUMBER, @@ -997,6 +977,24 @@ DISCOVERY_SCHEMAS = [ platform=Platform.LIGHT, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, ), + # light for Basic CC + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.BASIC}, + type={ValueType.NUMBER}, + property={CURRENT_VALUE_PROPERTY}, + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={ + CommandClass.BASIC, + }, + type={ValueType.NUMBER}, + property={TARGET_VALUE_PROPERTY}, + ) + ], + ), # sirens ZWaveDiscoverySchema( platform=Platform.SIREN, diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 1a9abb9b0f8..8ba50c15e02 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -129,11 +129,22 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): self._supported_color_modes: set[ColorMode] = set() # get additional (optional) values and set features + # If the command class is Basic, we must geenerate a name that includes + # the command class name to avoid ambiguity self._target_brightness = self.get_zwave_value( TARGET_VALUE_PROPERTY, CommandClass.SWITCH_MULTILEVEL, add_to_watched_value_ids=False, ) + if self.info.primary_value.command_class == CommandClass.BASIC: + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name="Basic" + ) + self._target_brightness = self.get_zwave_value( + TARGET_VALUE_PROPERTY, + CommandClass.BASIC, + add_to_watched_value_ids=False, + ) self._target_color = self.get_zwave_value( TARGET_COLOR_PROPERTY, CommandClass.SWITCH_COLOR, @@ -356,7 +367,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): # typically delayed and causes a confusing UX. if ( zwave_brightness == SET_TO_PREVIOUS_VALUE - and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL + and self.info.primary_value.command_class + in (CommandClass.BASIC, CommandClass.SWITCH_MULTILEVEL) ): self._set_optimistic_state = True self.async_write_ha_state() diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 606dda30b24..f4d7ea0a754 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -26,7 +26,7 @@ DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" -BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" +BASIC_LIGHT_ENTITY = "light.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( "binary_sensor.august_smart_lock_pro_3rd_gen_the_current_status_of_the_door" ) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 4b0345b00ea..dff9790634e 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -26,9 +26,11 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .common import ( AEON_SMART_SWITCH_LIGHT_ENTITY, + BASIC_LIGHT_ENTITY, BULB_6_MULTI_COLOR_LIGHT_ENTITY, EATON_RF9640_ENTITY, ZEN_31_ENTITY, @@ -859,3 +861,144 @@ async def test_black_is_off_zdb5100( "property": "targetColor", } assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + +async def test_basic_cc_light( + hass: HomeAssistant, client, ge_in_wall_dimmer_switch, integration +) -> None: + """Test light is created from Basic CC.""" + node = ge_in_wall_dimmer_switch + + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(BASIC_LIGHT_ENTITY) + + assert entity_entry + assert not entity_entry.disabled + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes["supported_features"] == 0 + + # Send value to 0 + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 2, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": None, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_OFF + + # Turn on light + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BASIC_LIGHT_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 255 + + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(BASIC_LIGHT_ENTITY) + + assert state + assert state.state == STATE_ON + + client.async_send_command.reset_mock() + + # Send value to 0 + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 2, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "currentValue", + "newValue": 0, + "prevValue": None, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BASIC_LIGHT_ENTITY) + assert state + assert state.state == STATE_OFF + + # Turn on light with brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": BASIC_LIGHT_ENTITY, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 50 + + # Since we specified a brightness, there is no optimistic update so the state + # should be off + state = hass.states.get(BASIC_LIGHT_ENTITY) + + assert state + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + # Turn off light + await hass.services.async_call( + "light", + "turn_off", + {"entity_id": BASIC_LIGHT_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 2 + assert args["valueId"] == { + "commandClass": 32, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 0 diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index 7a3ffbda589..b05d9e46f73 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -9,8 +9,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .common import BASIC_NUMBER_ENTITY - from tests.common import MockConfigEntry NUMBER_ENTITY = "number.thermostat_hvac_valve_control" @@ -219,18 +217,6 @@ async def test_volume_number( assert state.state == STATE_UNKNOWN -async def test_disabled_basic_number( - hass: HomeAssistant, ge_in_wall_dimmer_switch, integration -) -> None: - """Test number is created from Basic CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(BASIC_NUMBER_ENTITY) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - async def test_config_parameter_number( hass: HomeAssistant, climate_adc_t3000, integration ) -> None: From 85fa364152c0cc9df14355ef9be3ac1ca312cb5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 13 Oct 2023 21:45:35 +0200 Subject: [PATCH 423/968] Migrate Panasonic Viera to has entity name (#96746) Co-authored-by: Franck Nijhof --- .../panasonic_viera/media_player.py | 38 ++++++------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index a159c47a7c9..9e7fe4168ab 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -69,42 +69,28 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): | MediaPlayerEntityFeature.STOP | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _attr_has_entity_name = True + _attr_name = None def __init__(self, remote, name, device_info): """Initialize the entity.""" self._remote = remote - self._name = name - self._device_info = device_info - - @property - def unique_id(self): - """Return the unique ID of the device.""" - if self._device_info is None: - return None - return self._device_info[ATTR_UDN] - - @property - def device_info(self) -> DeviceInfo | None: - """Return device specific attributes.""" - if self._device_info is None: - return None - return DeviceInfo( - identifiers={(DOMAIN, self._device_info[ATTR_UDN])}, - manufacturer=self._device_info.get(ATTR_MANUFACTURER, DEFAULT_MANUFACTURER), - model=self._device_info.get(ATTR_MODEL_NUMBER, DEFAULT_MODEL_NUMBER), - name=self._name, - ) + if device_info is not None: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_info[ATTR_UDN])}, + manufacturer=device_info.get(ATTR_MANUFACTURER, DEFAULT_MANUFACTURER), + model=device_info.get(ATTR_MODEL_NUMBER, DEFAULT_MODEL_NUMBER), + name=name, + ) + self._attr_unique_id = device_info[ATTR_UDN] + else: + self._attr_name = name @property def device_class(self): """Return the device class of the device.""" return MediaPlayerDeviceClass.TV - @property - def name(self): - """Return the name of the device.""" - return self._name - @property def state(self): """Return the state of the device.""" From a0a3d91a28cc5670877c299a0f05f2abc72ff679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 13 Oct 2023 21:46:52 +0200 Subject: [PATCH 424/968] Update hass-nabucasa from 0.71.0 to 0.73.0 (#101939) --- homeassistant/components/cloud/client.py | 6 ++++++ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c216ec85c5c..41ea4aa2b7d 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -19,6 +19,7 @@ from homeassistant.components.alexa import ( from homeassistant.components.google_assistant import smart_home as ga from homeassistant.const import __version__ as HA_VERSION from homeassistant.core import Context, HassJob, HomeAssistant, callback +from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.aiohttp import MockRequest, serialize_response @@ -86,6 +87,11 @@ class CloudClient(Interface): """Return true if we want start a remote connection.""" return self._prefs.remote_enabled + @property + def client_name(self) -> str: + """Return the client name that will be used for API calls.""" + return SERVER_SOFTWARE + @property def relayer_region(self) -> str | None: """Return the connected relayer region.""" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index fe0628f1886..653ceafdaf0 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.71.0"] + "requirements": ["hass-nabucasa==0.73.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2a5ed1274b2..f4b8bdb73f3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.4 dbus-fast==2.11.1 fnv-hash-fast==0.4.1 ha-av==10.1.1 -hass-nabucasa==0.71.0 +hass-nabucasa==0.73.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20231005.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7fc8f713e17..b20c830f9a9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -967,7 +967,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.71.0 +hass-nabucasa==0.73.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f88ca559a4c..8a529492f1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -768,7 +768,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.71.0 +hass-nabucasa==0.73.0 # homeassistant.components.conversation hassil==1.2.5 From ce775667839d46702b63205fb733d7b4b95d05e2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 13 Oct 2023 13:58:27 -0600 Subject: [PATCH 425/968] Add more specific typing to OpenUV coordinator (#101952) --- homeassistant/components/openuv/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index 5d0c4bce50a..f82a85e19b0 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -17,7 +17,7 @@ from .const import LOGGER DEFAULT_DEBOUNCER_COOLDOWN_SECONDS = 15 * 60 -class OpenUvCoordinator(DataUpdateCoordinator): +class OpenUvCoordinator(DataUpdateCoordinator[dict[str, Any]]): """Define an OpenUV data coordinator.""" config_entry: ConfigEntry From a302f1a6167829ff2685d66645acb75c32c9c510 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:09:13 +0200 Subject: [PATCH 426/968] Set category and enabled by default of Minecraft Server sensors (#101943) * Use set instead of list for supported server types in sensor platform * Set sensor categories and enabled by default * Set edition and version as diagnostic sensors --- .../components/minecraft_server/sensor.py | 47 +++++++++++-------- 1 file changed, 27 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index e63649c9239..661ce00dac5 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -7,7 +7,7 @@ from typing import Any from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE, UnitOfTime +from homeassistant.const import CONF_TYPE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -47,7 +47,7 @@ class MinecraftServerEntityDescriptionMixin: value_fn: Callable[[MinecraftServerData], StateType] attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None - supported_server_types: list[MinecraftServerType] + supported_server_types: set[MinecraftServerType] @dataclass @@ -77,10 +77,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_VERSION, value_fn=lambda data: data.version, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, + entity_category=EntityCategory.DIAGNOSTIC, ), MinecraftServerSensorEntityDescription( key=KEY_PROTOCOL_VERSION, @@ -88,10 +89,12 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PROTOCOL_VERSION, value_fn=lambda data: data.protocol_version, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_MAX, @@ -100,10 +103,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_MAX, value_fn=lambda data: data.players_max, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, + entity_registry_enabled_default=False, ), MinecraftServerSensorEntityDescription( key=KEY_LATENCY, @@ -113,10 +117,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_LATENCY, value_fn=lambda data: data.latency, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, + entity_category=EntityCategory.DIAGNOSTIC, ), MinecraftServerSensorEntityDescription( key=KEY_MOTD, @@ -124,10 +129,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_MOTD, value_fn=lambda data: data.motd, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, ), MinecraftServerSensorEntityDescription( key=KEY_PLAYERS_ONLINE, @@ -136,10 +141,10 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_PLAYERS_ONLINE, value_fn=lambda data: data.players_online, attributes_fn=get_extra_state_attributes_players_list, - supported_server_types=[ + supported_server_types={ MinecraftServerType.JAVA_EDITION, MinecraftServerType.BEDROCK_EDITION, - ], + }, ), MinecraftServerSensorEntityDescription( key=KEY_EDITION, @@ -147,9 +152,11 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_EDITION, value_fn=lambda data: data.edition, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.BEDROCK_EDITION, - ], + }, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, ), MinecraftServerSensorEntityDescription( key=KEY_GAME_MODE, @@ -157,9 +164,9 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_GAME_MODE, value_fn=lambda data: data.game_mode, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.BEDROCK_EDITION, - ], + }, ), MinecraftServerSensorEntityDescription( key=KEY_MAP_NAME, @@ -167,9 +174,9 @@ SENSOR_DESCRIPTIONS = [ icon=ICON_MAP_NAME, value_fn=lambda data: data.map_name, attributes_fn=None, - supported_server_types=[ + supported_server_types={ MinecraftServerType.BEDROCK_EDITION, - ], + }, ), ] From 6a6f6fd99d74763e8d54fa3da646690b3b53a176 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Fri, 13 Oct 2023 22:56:02 +0200 Subject: [PATCH 427/968] Update pre-commit to 3.5.0 (#101956) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 47d8fe1dcfe..d2bdbf30b71 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 mypy==1.6.0 -pre-commit==3.4.0 +pre-commit==3.5.0 pydantic==1.10.12 pylint==3.0.1 pylint-per-file-ignores==1.2.1 From ae7bb60677d036c0f5437fdceb76386578facf2b Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Fri, 13 Oct 2023 23:13:39 +0200 Subject: [PATCH 428/968] Fix types in ViCare integration (#101926) * fix type * fix return type * fix type * fix type * Apply suggestions from code review * Update climate.py * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Update __init__.py * Update climate.py * Update __init__.py * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Update binary_sensor.py * Update button.py * Update sensor.py --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/vicare/__init__.py | 7 ++++--- homeassistant/components/vicare/binary_sensor.py | 12 +++++++++--- homeassistant/components/vicare/button.py | 4 +++- homeassistant/components/vicare/climate.py | 10 ++++++---- homeassistant/components/vicare/sensor.py | 12 +++++++++--- homeassistant/components/vicare/water_heater.py | 2 +- 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 269695a668d..587c98da693 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -1,11 +1,12 @@ """The ViCare integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import suppress from dataclasses import dataclass import logging import os +from typing import Any from PyViCare.PyViCare import PyViCare from PyViCare.PyViCareDevice import Device @@ -59,7 +60,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -def vicare_login(hass, entry_data): +def vicare_login(hass: HomeAssistant, entry_data: Mapping[str, Any]) -> PyViCare: """Login via PyVicare API.""" vicare_api = PyViCare() vicare_api.setCacheDuration(DEFAULT_SCAN_INTERVAL) @@ -72,7 +73,7 @@ def vicare_login(hass, entry_data): return vicare_api -def setup_vicare_api(hass, entry): +def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: """Set up PyVicare API.""" vicare_api = vicare_login(hass, entry.data) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 0a54b472c07..17cee394ade 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -96,7 +96,9 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ) -def _build_entity(name, vicare_api, device_config, sensor): +def _build_entity( + name: str, vicare_api, device_config, sensor: ViCareBinarySensorEntityDescription +): """Create a ViCare binary sensor entity.""" try: sensor.value_getter(vicare_api) @@ -117,8 +119,12 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, entities, sensor_descriptions, iterables, config_entry -): + hass: HomeAssistant, + entities: list[ViCareBinarySensor], + sensor_descriptions: tuple[ViCareBinarySensorEntityDescription, ...], + iterables, + config_entry: ConfigEntry, +) -> None: """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 3a143d411c2..4cbcf811fbc 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -46,7 +46,9 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( ) -def _build_entity(name, vicare_api, device_config, description): +def _build_entity( + name: str, vicare_api, device_config, description: ViCareButtonEntityDescription +): """Create a ViCare button entity.""" _LOGGER.debug("Found device %s", name) try: diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 5ae642ceacd..d306cc6604d 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -67,7 +67,7 @@ VICARE_HOLD_MODE_OFF = "off" VICARE_TEMP_HEATING_MIN = 3 VICARE_TEMP_HEATING_MAX = 37 -VICARE_TO_HA_HVAC_HEATING = { +VICARE_TO_HA_HVAC_HEATING: dict[str, HVACMode] = { VICARE_MODE_FORCEDREDUCED: HVACMode.OFF, VICARE_MODE_OFF: HVACMode.OFF, VICARE_MODE_DHW: HVACMode.OFF, @@ -146,15 +146,15 @@ class ViCareClimate(ViCareEntity, ClimateEntity): _attr_target_temperature_step = PRECISION_WHOLE _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) _current_action: bool | None = None + _current_mode: str | None = None - def __init__(self, name, api, circuit, device_config): + def __init__(self, name, api, circuit, device_config) -> None: """Initialize the climate device.""" super().__init__(device_config) self._attr_name = name self._api = api self._circuit = circuit self._attributes: dict[str, Any] = {} - self._current_mode = None self._current_program = None self._attr_unique_id = f"{device_config.getConfig().serial}-{circuit.id}" @@ -230,7 +230,9 @@ class ViCareClimate(ViCareEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" - return VICARE_TO_HA_HVAC_HEATING.get(self._current_mode) + if self._current_mode is None: + return None + return VICARE_TO_HA_HVAC_HEATING.get(self._current_mode, None) def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set a new hvac mode on the ViCare API.""" diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 1810192d0ba..a7aa93f30bb 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -573,7 +573,9 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( ) -def _build_entity(name, vicare_api, device_config, sensor): +def _build_entity( + name: str, vicare_api, device_config, sensor: ViCareSensorEntityDescription +): """Create a ViCare sensor entity.""" _LOGGER.debug("Found device %s", name) try: @@ -595,8 +597,12 @@ def _build_entity(name, vicare_api, device_config, sensor): async def _entities_from_descriptions( - hass, entities, sensor_descriptions, iterables, config_entry -): + hass: HomeAssistant, + entities: list[ViCareSensor], + sensor_descriptions: tuple[ViCareSensorEntityDescription, ...], + iterables, + config_entry: ConfigEntry, +) -> None: """Create entities from descriptions and list of burners/circuits.""" for description in sensor_descriptions: for current in iterables: diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 59e0bb522f4..db8a959f4ae 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -99,7 +99,7 @@ class ViCareWater(ViCareEntity, WaterHeaterEntity): _attr_max_temp = VICARE_TEMP_WATER_MAX _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config): + def __init__(self, name, api, circuit, device_config) -> None: """Initialize the DHW water_heater device.""" super().__init__(device_config) self._attr_name = name From 2e3013f2f81bc22ad6229409e18483442e575be4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 13 Oct 2023 19:14:43 -0400 Subject: [PATCH 429/968] Update zwave issue repair strings (#101940) --- homeassistant/components/zwave_js/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 6994ce15a0c..4bb9494eb6b 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -163,12 +163,12 @@ } }, "device_config_file_changed": { - "title": "Z-Wave device configuration file changed: {device_name}", + "title": "Device configuration file changed: {device_name}", "fix_flow": { "step": { "confirm": { - "title": "Z-Wave device configuration file changed: {device_name}", - "description": "Z-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." + "title": "Device configuration file changed: {device_name}", + "description": "The device configuration file for {device_name} has changed.\n\nZ-Wave JS discovers a lot of device metadata by interviewing the device. However, some of the information has to be loaded from a configuration file. Some of this information is only evaluated once, during the device interview.\n\nWhen a device config file is updated, this information may be stale and and the device must be re-interviewed to pick up the changes.\n\n This is not a required operation and device functionality will be impacted during the re-interview process, but you may see improvements for your device once it is complete.\n\nIf you'd like to proceed, click on SUBMIT below. The re-interview will take place in the background." } }, "abort": { From 76e2afbce909578854c5b5a47b7f664dcfb1d9ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 14:03:01 -1000 Subject: [PATCH 430/968] Add some more typing to HomeKit (#101959) --- .../components/homekit/type_media_players.py | 58 ++++++++++--------- .../components/homekit/type_remotes.py | 14 +++-- .../homekit/type_security_systems.py | 10 ++-- .../components/homekit/type_sensors.py | 58 ++++++++++--------- 4 files changed, 77 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index da7fdceede3..23fbd5b454d 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -2,6 +2,7 @@ import logging from typing import Any +from pyhap.characteristic import Characteristic from pyhap.const import CATEGORY_SWITCH from homeassistant.components.media_player import ( @@ -32,7 +33,7 @@ from homeassistant.const import ( STATE_STANDBY, STATE_UNKNOWN, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -82,11 +83,12 @@ MEDIA_PLAYER_OFF_STATES = ( class MediaPlayer(HomeAccessory): """Generate a Media Player accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) state = self.hass.states.get(self.entity_id) - self.chars = { + assert state + self.chars: dict[str, Characteristic | None] = { FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, FEATURE_PLAY_STOP: None, @@ -137,20 +139,20 @@ class MediaPlayer(HomeAccessory): ) self.async_update_state(state) - def generate_service_name(self, mode): + def generate_service_name(self, mode: str) -> str: """Generate name for individual service.""" return cleanup_name_for_homekit( f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" ) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_play_pause(self, value): + def set_play_pause(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "play_pause" to %s', self.entity_id, value @@ -159,7 +161,7 @@ class MediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_play_stop(self, value): + def set_play_stop(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "play_stop" to %s', self.entity_id, value @@ -168,7 +170,7 @@ class MediaPlayer(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_toggle_mute(self, value): + def set_toggle_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value @@ -177,43 +179,43 @@ class MediaPlayer(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update switch state after state changed.""" current_state = new_state.state - if self.chars[FEATURE_ON_OFF]: + if on_off_char := self.chars[FEATURE_ON_OFF]: hk_state = current_state not in MEDIA_PLAYER_OFF_STATES _LOGGER.debug( '%s: Set current state for "on_off" to %s', self.entity_id, hk_state ) - self.chars[FEATURE_ON_OFF].set_value(hk_state) + on_off_char.set_value(hk_state) - if self.chars[FEATURE_PLAY_PAUSE]: + if play_pause_char := self.chars[FEATURE_PLAY_PAUSE]: hk_state = current_state == STATE_PLAYING _LOGGER.debug( '%s: Set current state for "play_pause" to %s', self.entity_id, hk_state, ) - self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + play_pause_char.set_value(hk_state) - if self.chars[FEATURE_PLAY_STOP]: + if play_stop_char := self.chars[FEATURE_PLAY_STOP]: hk_state = current_state == STATE_PLAYING _LOGGER.debug( '%s: Set current state for "play_stop" to %s', self.entity_id, hk_state, ) - self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + play_stop_char.set_value(hk_state) - if self.chars[FEATURE_TOGGLE_MUTE]: - current_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) + if toggle_mute_char := self.chars[FEATURE_TOGGLE_MUTE]: + mute_state = bool(new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)) _LOGGER.debug( '%s: Set current state for "toggle_mute" to %s', self.entity_id, - current_state, + mute_state, ) - self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + toggle_mute_char.set_value(mute_state) @TYPES.register("TelevisionMediaPlayer") @@ -278,14 +280,14 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): self.async_update_state(state) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_mute(self, value): + def set_mute(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug( '%s: Set switch state for "toggle_mute" to %s', self.entity_id, value @@ -293,27 +295,27 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_MUTED: value} self.async_call_service(DOMAIN, SERVICE_VOLUME_MUTE, params) - def set_volume(self, value): + def set_volume(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Set volume to %s", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_MEDIA_VOLUME_LEVEL: value} self.async_call_service(DOMAIN, SERVICE_VOLUME_SET, params) - def set_volume_step(self, value): + def set_volume_step(self, value: bool) -> None: """Send volume step value if call came from HomeKit.""" _LOGGER.debug("%s: Step volume by %s", self.entity_id, value) service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(DOMAIN, service, params) - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source_name = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_INPUT_SOURCE: source_name} self.async_call_service(DOMAIN, SERVICE_SELECT_SOURCE, params) - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) if (key_name := REMOTE_KEYS.get(value)) is None: @@ -322,7 +324,9 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): if key_name == KEY_PLAY_PAUSE and self._supports_play_pause: # Handle Play Pause by directly updating the media player entity. - state = self.hass.states.get(self.entity_id).state + state_obj = self.hass.states.get(self.entity_id) + assert state_obj + state = state_obj.state if state in (STATE_PLAYING, STATE_PAUSED): service = ( SERVICE_MEDIA_PLAY if state == STATE_PAUSED else SERVICE_MEDIA_PAUSE @@ -340,7 +344,7 @@ class TelevisionMediaPlayer(RemoteInputSelectAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update Television state after state changed.""" current_state = new_state.state diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 5dfc9777964..0f6e22abe1d 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -219,7 +219,7 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): class ActivityRemote(RemoteInputSelectAccessory): """Generate a Activity Remote accessory.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Activity Remote accessory object.""" super().__init__( RemoteEntityFeature.ACTIVITY, @@ -227,23 +227,25 @@ class ActivityRemote(RemoteInputSelectAccessory): ATTR_ACTIVITY_LIST, *args, ) - self.async_update_state(self.hass.states.get(self.entity_id)) + state = self.hass.states.get(self.entity_id) + assert state + self.async_update_state(state) - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" _LOGGER.debug('%s: Set switch state for "on_off" to %s', self.entity_id, value) service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF params = {ATTR_ENTITY_ID: self.entity_id} self.async_call_service(REMOTE_DOMAIN, service, params) - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" _LOGGER.debug("%s: Set current input to %s", self.entity_id, value) source = self._mapped_sources[self.sources[value]] params = {ATTR_ENTITY_ID: self.entity_id, ATTR_ACTIVITY: source} self.async_call_service(REMOTE_DOMAIN, SERVICE_TURN_ON, params) - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" _LOGGER.debug("%s: Set remote key to %s", self.entity_id, value) if (key_name := REMOTE_KEYS.get(value)) is None: @@ -255,7 +257,7 @@ class ActivityRemote(RemoteInputSelectAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update Television remote state after state changed.""" current_state = new_state.state # Power state remote diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index f9c881339ce..de2c463bfb2 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -1,5 +1,6 @@ """Class to hold all alarm control panel accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_ALARM_SYSTEM @@ -23,7 +24,7 @@ from homeassistant.const import ( STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -78,10 +79,11 @@ HK_TO_SERVICE = { class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a SecuritySystem accessory object.""" super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) state = self.hass.states.get(self.entity_id) + assert state self._alarm_code = self.config.get(ATTR_CODE) supported_states = state.attributes.get( @@ -143,7 +145,7 @@ class SecuritySystem(HomeAccessory): # GET to avoid an event storm after homekit startup self.async_update_state(state) - def set_security_state(self, value): + def set_security_state(self, value: int) -> None: """Move security state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set security state to %d", self.entity_id, value) service = HK_TO_SERVICE[value] @@ -153,7 +155,7 @@ class SecuritySystem(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update security state after state changed.""" hass_state = new_state.state if (current_state := HASS_TO_HOMEKIT_CURRENT.get(hass_state)) is not None: diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 240cdd888d2..dbf2808a55a 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable import logging -from typing import NamedTuple +from typing import Any, NamedTuple from pyhap.const import CATEGORY_SENSOR from pyhap.service import Service @@ -16,7 +16,7 @@ from homeassistant.const import ( STATE_ON, UnitOfTemperature, ) -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import ( @@ -112,10 +112,11 @@ class TemperatureSensor(HomeAccessory): Sensor entity must return temperature in °C, °F. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a TemperatureSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) self.char_temp = serv_temp.configure_char( CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS @@ -125,7 +126,7 @@ class TemperatureSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update temperature after state changed.""" unit = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT, UnitOfTemperature.CELSIUS @@ -142,10 +143,11 @@ class TemperatureSensor(HomeAccessory): class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a HumiditySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) self.char_humidity = serv_humidity.configure_char( CHAR_CURRENT_HUMIDITY, value=0 @@ -155,7 +157,7 @@ class HumiditySensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (humidity := convert_to_float(new_state.state)) is not None: self.char_humidity.set_value(humidity) @@ -166,18 +168,18 @@ class HumiditySensor(HomeAccessory): class AirQualitySensor(HomeAccessory): """Generate a AirQualitySensor accessory as air quality sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a AirQualitySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) - + assert state self.create_services() # Set the state so it is in sync on initial # GET to avoid an event storm after homekit startup self.async_update_state(state) - def create_services(self): + def create_services(self) -> None: """Initialize a AirQualitySensor accessory object.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_AIR_PARTICULATE_DENSITY] @@ -188,7 +190,7 @@ class AirQualitySensor(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (density := convert_to_float(new_state.state)) is not None: if self.char_density.value != density: @@ -203,7 +205,7 @@ class AirQualitySensor(HomeAccessory): class PM10Sensor(AirQualitySensor): """Generate a PM10Sensor accessory as PM 10 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 10 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_PM10_DENSITY] @@ -212,7 +214,7 @@ class PM10Sensor(AirQualitySensor): self.char_density = serv_air_quality.configure_char(CHAR_PM10_DENSITY, value=0) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -230,7 +232,7 @@ class PM10Sensor(AirQualitySensor): class PM25Sensor(AirQualitySensor): """Generate a PM25Sensor accessory as PM 2.5 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 2.5 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_PM25_DENSITY] @@ -239,7 +241,7 @@ class PM25Sensor(AirQualitySensor): self.char_density = serv_air_quality.configure_char(CHAR_PM25_DENSITY, value=0) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -257,7 +259,7 @@ class PM25Sensor(AirQualitySensor): class NitrogenDioxideSensor(AirQualitySensor): """Generate a NitrogenDioxideSensor accessory as NO2 sensor.""" - def create_services(self): + def create_services(self) -> None: """Override the init function for PM 2.5 Sensor.""" serv_air_quality = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_NITROGEN_DIOXIDE_DENSITY] @@ -268,7 +270,7 @@ class NitrogenDioxideSensor(AirQualitySensor): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -289,7 +291,7 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): Sensor entity must return VOC in µg/m3. """ - def create_services(self): + def create_services(self) -> None: """Override the init function for VOC Sensor.""" serv_air_quality: Service = self.add_preload_service( SERV_AIR_QUALITY_SENSOR, [CHAR_VOC_DENSITY] @@ -305,7 +307,7 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" density = convert_to_float(new_state.state) if density is None: @@ -323,10 +325,11 @@ class VolatileOrganicCompoundsSensor(AirQualitySensor): class CarbonMonoxideSensor(HomeAccessory): """Generate a CarbonMonoxidSensor accessory as CO sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a CarbonMonoxideSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_co = self.add_preload_service( SERV_CARBON_MONOXIDE_SENSOR, [CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL], @@ -343,7 +346,7 @@ class CarbonMonoxideSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (value := convert_to_float(new_state.state)) is not None: self.char_level.set_value(value) @@ -358,10 +361,11 @@ class CarbonMonoxideSensor(HomeAccessory): class CarbonDioxideSensor(HomeAccessory): """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a CarbonDioxideSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_co2 = self.add_preload_service( SERV_CARBON_DIOXIDE_SENSOR, [CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL], @@ -378,7 +382,7 @@ class CarbonDioxideSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (value := convert_to_float(new_state.state)) is not None: self.char_level.set_value(value) @@ -393,10 +397,11 @@ class CarbonDioxideSensor(HomeAccessory): class LightSensor(HomeAccessory): """Generate a LightSensor accessory as light sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a LightSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state serv_light = self.add_preload_service(SERV_LIGHT_SENSOR) self.char_light = serv_light.configure_char( CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0 @@ -406,7 +411,7 @@ class LightSensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" if (luminance := convert_to_float(new_state.state)) is not None: self.char_light.set_value(luminance) @@ -417,10 +422,11 @@ class LightSensor(HomeAccessory): class BinarySensor(HomeAccessory): """Generate a BinarySensor accessory as binary sensor.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a BinarySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) state = self.hass.states.get(self.entity_id) + assert state device_class = state.attributes.get(ATTR_DEVICE_CLASS) service_char = ( BINARY_SENSOR_SERVICE_MAP[device_class] @@ -439,7 +445,7 @@ class BinarySensor(HomeAccessory): self.async_update_state(state) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update accessory after state change.""" state = new_state.state detected = self.format(state in (STATE_ON, STATE_HOME)) From 371d988643d6838eba20f8f78493e5c1f803fd1b Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 13 Oct 2023 18:12:00 -0600 Subject: [PATCH 431/968] Simplify state update logic for OpenUV sensors (#101972) * Clean up OpenUV entity state logic * Reduce * Remove old file * Simplify --- homeassistant/components/openuv/__init__.py | 18 +-- .../components/openuv/binary_sensor.py | 4 +- homeassistant/components/openuv/sensor.py | 142 +++++++++++------- 3 files changed, 94 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 4df91cf4e15..048ffdd237b 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -16,7 +16,7 @@ from homeassistant.const import ( CONF_SENSORS, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -132,19 +132,3 @@ class OpenUvEntity(CoordinatorEntity): name="OpenUV", entry_type=DeviceEntryType.SERVICE, ) - - @callback - def _handle_coordinator_update(self) -> None: - """Respond to a DataUpdateCoordinator update.""" - self._update_from_latest_data() - self.async_write_ha_state() - - @callback - def _update_from_latest_data(self) -> None: - """Update the entity from the latest data.""" - raise NotImplementedError - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - self._update_from_latest_data() diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index e9f9ee99ff6..9c970f34dc3 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -45,7 +45,7 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): """Define a binary sensor for OpenUV.""" @callback - def _update_from_latest_data(self) -> None: + def _handle_coordinator_update(self) -> None: """Update the entity from the latest data.""" data = self.coordinator.data @@ -76,3 +76,5 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorEntity): ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), } ) + + super()._handle_coordinator_update() diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 90eefac594a..6c4bff855a4 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,6 +1,10 @@ """Support for OpenUV sensors.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, @@ -8,7 +12,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UV_INDEX, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import as_local, parse_datetime @@ -40,79 +44,135 @@ EXPOSURE_TYPE_MAP = { TYPE_SAFE_EXPOSURE_TIME_6: "st6", } -UV_LEVEL_EXTREME = "Extreme" -UV_LEVEL_VHIGH = "Very High" -UV_LEVEL_HIGH = "High" -UV_LEVEL_MODERATE = "Moderate" -UV_LEVEL_LOW = "Low" + +@dataclass +class UvLabel: + """Define a friendly UV level label and its minimum UV index.""" + + value: str + minimum_index: int + + +UV_LABEL_DEFINITIONS = ( + UvLabel(value="Extreme", minimum_index=11), + UvLabel(value="Very High", minimum_index=8), + UvLabel(value="High", minimum_index=6), + UvLabel(value="Moderate", minimum_index=3), + UvLabel(value="Low", minimum_index=0), +) + + +def get_uv_label(uv_index: int) -> str: + """Return the UV label for the UV index.""" + label = next( + label for label in UV_LABEL_DEFINITIONS if uv_index >= label.minimum_index + ) + return label.value + + +@dataclass +class OpenUvSensorEntityDescriptionMixin: + """Define a mixin for OpenUV sensor descriptions.""" + + value_fn: Callable[[dict[str, Any]], int | str] + + +@dataclass +class OpenUvSensorEntityDescription( + SensorEntityDescription, OpenUvSensorEntityDescriptionMixin +): + """Define a class that describes OpenUV sensor entities.""" + SENSOR_DESCRIPTIONS = ( - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_OZONE_LEVEL, translation_key="current_ozone_level", native_unit_of_measurement="du", state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["ozone"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_UV_INDEX, translation_key="current_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["uv"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_CURRENT_UV_LEVEL, translation_key="current_uv_level", icon="mdi:weather-sunny", + value_fn=lambda data: get_uv_label(data["uv"]), ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_MAX_UV_INDEX, translation_key="max_uv_index", icon="mdi:weather-sunny", native_unit_of_measurement=UV_INDEX, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["uv_max"], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_1, translation_key="skin_type_1_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_1] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_2, translation_key="skin_type_2_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_2] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_3, translation_key="skin_type_3_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_3] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_4, translation_key="skin_type_4_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_4] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_5, translation_key="skin_type_5_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_5] + ], ), - SensorEntityDescription( + OpenUvSensorEntityDescription( key=TYPE_SAFE_EXPOSURE_TIME_6, translation_key="skin_type_6_safe_exposure_time", icon="mdi:timer-outline", native_unit_of_measurement=UnitOfTime.MINUTES, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: data["safe_exposure_time"][ + EXPOSURE_TYPE_MAP[TYPE_SAFE_EXPOSURE_TIME_6] + ], ), ) @@ -134,40 +194,18 @@ async def async_setup_entry( class OpenUvSensor(OpenUvEntity, SensorEntity): """Define a binary sensor for OpenUV.""" - @callback - def _update_from_latest_data(self) -> None: - """Update the state.""" - data = self.coordinator.data + entity_description: OpenUvSensorEntityDescription - if self.entity_description.key == TYPE_CURRENT_OZONE_LEVEL: - self._attr_native_value = data["ozone"] - elif self.entity_description.key == TYPE_CURRENT_UV_INDEX: - self._attr_native_value = data["uv"] - elif self.entity_description.key == TYPE_CURRENT_UV_LEVEL: - if data["uv"] >= 11: - self._attr_native_value = UV_LEVEL_EXTREME - elif data["uv"] >= 8: - self._attr_native_value = UV_LEVEL_VHIGH - elif data["uv"] >= 6: - self._attr_native_value = UV_LEVEL_HIGH - elif data["uv"] >= 3: - self._attr_native_value = UV_LEVEL_MODERATE - else: - self._attr_native_value = UV_LEVEL_LOW - elif self.entity_description.key == TYPE_MAX_UV_INDEX: - self._attr_native_value = data["uv_max"] - if uv_max_time := parse_datetime(data["uv_max_time"]): - self._attr_extra_state_attributes.update( - {ATTR_MAX_UV_TIME: as_local(uv_max_time)} - ) - elif self.entity_description.key in ( - TYPE_SAFE_EXPOSURE_TIME_1, - TYPE_SAFE_EXPOSURE_TIME_2, - TYPE_SAFE_EXPOSURE_TIME_3, - TYPE_SAFE_EXPOSURE_TIME_4, - TYPE_SAFE_EXPOSURE_TIME_5, - TYPE_SAFE_EXPOSURE_TIME_6, - ): - self._attr_native_value = data["safe_exposure_time"][ - EXPOSURE_TYPE_MAP[self.entity_description.key] - ] + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return entity specific state attributes.""" + attrs = {} + if self.entity_description.key == TYPE_MAX_UV_INDEX: + if uv_max_time := parse_datetime(self.coordinator.data["uv_max_time"]): + attrs[ATTR_MAX_UV_TIME] = as_local(uv_max_time) + return attrs + + @property + def native_value(self) -> int | str: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.data) From 8fd5d89d4372b44c44d954c1d5f6417435adb6ce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 14:23:15 -1000 Subject: [PATCH 432/968] Avoid polling state machine for available state in HomeKit (#101799) --- homeassistant/components/homekit/accessories.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 2e0d1e6c052..a14e0add488 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -373,6 +373,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] """Add battery service if available""" state = self.hass.states.get(self.entity_id) + self._update_available_from_state(state) assert state is not None entity_attributes = state.attributes battery_found = entity_attributes.get(ATTR_BATTERY_LEVEL) @@ -415,16 +416,20 @@ class HomeAccessory(Accessory): # type: ignore[misc] CHAR_STATUS_LOW_BATTERY, value=0 ) + def _update_available_from_state(self, new_state: State | None) -> None: + """Update the available property based on the state.""" + self._available = new_state is not None and new_state.state != STATE_UNAVAILABLE + @property def available(self) -> bool: """Return if accessory is available.""" - state = self.hass.states.get(self.entity_id) - return state is not None and state.state != STATE_UNAVAILABLE + return self._available async def run(self) -> None: """Handle accessory driver started event.""" if state := self.hass.states.get(self.entity_id): self.async_update_state_callback(state) + self._update_available_from_state(state) self._subscriptions.append( async_track_state_change_event( self.hass, [self.entity_id], self.async_update_event_state_callback @@ -474,6 +479,7 @@ class HomeAccessory(Accessory): # type: ignore[misc] """Handle state change event listener callback.""" new_state = event.data["new_state"] old_state = event.data["old_state"] + self._update_available_from_state(new_state) if ( new_state and old_state From f8f39a29de8c1069ff16a937026dec352e7abbce Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 14:23:50 -1000 Subject: [PATCH 433/968] Update HomeKit humidifiers to handle current humidity (#101964) --- .../components/homekit/type_humidifiers.py | 81 +++++++++++++------ .../homekit/test_type_humidifiers.py | 40 ++++++++- 2 files changed, 94 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/homekit/type_humidifiers.py b/homeassistant/components/homekit/type_humidifiers.py index d296b293820..939c1bf37ae 100644 --- a/homeassistant/components/homekit/type_humidifiers.py +++ b/homeassistant/components/homekit/type_humidifiers.py @@ -5,6 +5,7 @@ from typing import Any from pyhap.const import CATEGORY_HUMIDIFIER from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -64,11 +65,28 @@ HC_DEVICE_CLASS_TO_TARGET_CHAR = { HC_DEHUMIDIFIER: CHAR_DEHUMIDIFIER_THRESHOLD_HUMIDITY, } + HC_STATE_INACTIVE = 0 HC_STATE_IDLE = 1 HC_STATE_HUMIDIFYING = 2 HC_STATE_DEHUMIDIFYING = 3 +BASE_VALID_VALUES = { + "Inactive": HC_STATE_INACTIVE, + "Idle": HC_STATE_IDLE, +} + +VALID_VALUES_BY_DEVICE_CLASS = { + HumidifierDeviceClass.HUMIDIFIER: { + **BASE_VALID_VALUES, + "Humidifying": HC_STATE_HUMIDIFYING, + }, + HumidifierDeviceClass.DEHUMIDIFIER: { + **BASE_VALID_VALUES, + "Dehumidifying": HC_STATE_DEHUMIDIFYING, + }, +} + @TYPES.register("HumidifierDehumidifier") class HumidifierDehumidifier(HomeAccessory): @@ -85,7 +103,8 @@ class HumidifierDehumidifier(HomeAccessory): ) self.chars: list[str] = [] - state = self.hass.states.get(self.entity_id) + states = self.hass.states + state = states.get(self.entity_id) assert state device_class = state.attributes.get( ATTR_DEVICE_CLASS, HumidifierDeviceClass.HUMIDIFIER @@ -104,7 +123,9 @@ class HumidifierDehumidifier(HomeAccessory): # Current and target mode characteristics self.char_current_humidifier_dehumidifier = ( serv_humidifier_dehumidifier.configure_char( - CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, value=0 + CHAR_CURRENT_HUMIDIFIER_DEHUMIDIFIER, + value=0, + valid_values=VALID_VALUES_BY_DEVICE_CLASS[device_class], ) ) self.char_target_humidifier_dehumidifier = ( @@ -149,8 +170,7 @@ class HumidifierDehumidifier(HomeAccessory): self.linked_humidity_sensor = self.config.get(CONF_LINKED_HUMIDITY_SENSOR) if self.linked_humidity_sensor: - humidity_state = self.hass.states.get(self.linked_humidity_sensor) - if humidity_state: + if humidity_state := states.get(self.linked_humidity_sensor): self._async_update_current_humidity(humidity_state) async def run(self) -> None: @@ -191,14 +211,6 @@ class HumidifierDehumidifier(HomeAccessory): return try: current_humidity = float(new_state.state) - if self.char_current_humidity.value != current_humidity: - _LOGGER.debug( - "%s: Linked humidity sensor %s changed to %d", - self.entity_id, - self.linked_humidity_sensor, - current_humidity, - ) - self.char_current_humidity.set_value(current_humidity) except ValueError as ex: _LOGGER.debug( "%s: Unable to update from linked humidity sensor %s: %s", @@ -206,6 +218,20 @@ class HumidifierDehumidifier(HomeAccessory): self.linked_humidity_sensor, ex, ) + return + self._async_update_current_humidity_value(current_humidity) + + @callback + def _async_update_current_humidity_value(self, current_humidity: float) -> None: + """Handle linked humidity or built-in humidity.""" + if self.char_current_humidity.value != current_humidity: + _LOGGER.debug( + "%s: Linked humidity sensor %s changed to %d", + self.entity_id, + self.linked_humidity_sensor, + current_humidity, + ) + self.char_current_humidity.set_value(current_humidity) def _set_chars(self, char_values: dict[str, Any]) -> None: """Set characteristics based on the data coming from HomeKit.""" @@ -229,19 +255,7 @@ class HumidifierDehumidifier(HomeAccessory): if self._target_humidity_char_name in char_values: state = self.hass.states.get(self.entity_id) assert state - max_humidity = state.attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY) - max_humidity = round(max_humidity) - max_humidity = min(max_humidity, 100) - - min_humidity = state.attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) - min_humidity = round(min_humidity) - min_humidity = max(min_humidity, 0) - # The min/max humidity values here should be clamped to the HomeKit - # min/max that was set when the accessory was added to HomeKit so - # that the user cannot set a value outside of the range that was - # originally set as it could cause HomeKit to report the accessory - # as not responding. - + min_humidity, max_humidity = self.get_humidity_range(state) humidity = round(char_values[self._target_humidity_char_name]) if (humidity < min_humidity) or (humidity > max_humidity): @@ -260,10 +274,22 @@ class HumidifierDehumidifier(HomeAccessory): ), ) + def get_humidity_range(self, state: State) -> tuple[int, int]: + """Return min and max humidity range.""" + attributes = state.attributes + min_humidity = max( + int(round(attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY))), 0 + ) + max_humidity = min( + int(round(attributes.get(ATTR_MAX_HUMIDITY, DEFAULT_MAX_HUMIDITY))), 100 + ) + return min_humidity, max_humidity + @callback def async_update_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" is_active = new_state.state == STATE_ON + attributes = new_state.attributes # Update active state self.char_active.set_value(is_active) @@ -279,6 +305,9 @@ class HumidifierDehumidifier(HomeAccessory): self.char_current_humidifier_dehumidifier.set_value(current_state) # Update target humidity - target_humidity = new_state.attributes.get(ATTR_HUMIDITY) + target_humidity = attributes.get(ATTR_HUMIDITY) if isinstance(target_humidity, (int, float)): self.char_target_humidity.set_value(target_humidity) + current_humidity = attributes.get(ATTR_CURRENT_HUMIDITY) + if isinstance(current_humidity, (int, float)): + self.char_current_humidity.set_value(current_humidity) diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py index e0b3e40967f..c8c4f398375 100644 --- a/tests/components/homekit/test_type_humidifiers.py +++ b/tests/components/homekit/test_type_humidifiers.py @@ -1,4 +1,5 @@ """Test different accessory types: HumidifierDehumidifier.""" +from pyhap.accessory_driver import AccessoryDriver from pyhap.const import ( CATEGORY_HUMIDIFIER, HAP_REPR_AID, @@ -18,6 +19,7 @@ from homeassistant.components.homekit.const import ( ) from homeassistant.components.homekit.type_humidifiers import HumidifierDehumidifier from homeassistant.components.humidifier import ( + ATTR_CURRENT_HUMIDITY, ATTR_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, @@ -75,7 +77,11 @@ async def test_humidifier(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { "Humidifier": 1 } - + assert acc.char_current_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Humidifying": 2, + "Idle": 1, + "Inactive": 0, + } hass.states.async_set( entity_id, STATE_ON, @@ -156,6 +162,11 @@ async def test_dehumidifier(hass: HomeAssistant, hk_driver, events) -> None: assert acc.char_target_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { "Dehumidifier": 2 } + assert acc.char_current_humidifier_dehumidifier.properties[PROP_VALID_VALUES] == { + "Dehumidifying": 3, + "Idle": 1, + "Inactive": 0, + } hass.states.async_set( entity_id, @@ -523,3 +534,30 @@ async def test_dehumidifier_as_humidifier( await hass.async_block_till_done() assert "TargetHumidifierDehumidifierState is not supported" in caplog.text assert len(events) == 0 + + +async def test_humidifier_that_reports_current_humidity( + hass: HomeAssistant, hk_driver: AccessoryDriver +) -> None: + """Test a humidifier that provides current humidity can update.""" + entity_id = "humidifier.test" + hass.states.async_set(entity_id, STATE_OFF, {ATTR_CURRENT_HUMIDITY: 42}) + await hass.async_block_till_done() + acc = HumidifierDehumidifier( + hass, + hk_driver, + "HumidifierDehumidifier", + entity_id, + 1, + {}, + ) + hk_driver.add_accessory(acc) + + await acc.run() + await hass.async_block_till_done() + + assert acc.char_current_humidity.value == 42.0 + hass.states.async_set(entity_id, STATE_OFF, {ATTR_CURRENT_HUMIDITY: 43}) + + await hass.async_block_till_done() + assert acc.char_current_humidity.value == 43.0 From 0e499e07d2030c5681394ad9a12b5f5813b7f911 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 14:24:23 -1000 Subject: [PATCH 434/968] Small cleanups to HomeKit thermostats (#101962) --- .../components/homekit/type_thermostats.py | 100 ++++++++++-------- .../homekit/test_type_thermostats.py | 60 ++++++++--- 2 files changed, 102 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 85ad713012b..1fc8b3f2430 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -1,5 +1,6 @@ """Class to hold all thermostat accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_THERMOSTAT @@ -56,6 +57,7 @@ from homeassistant.const import ( UnitOfTemperature, ) from homeassistant.core import State, callback +from homeassistant.util.enum import try_parse_enum from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -163,17 +165,27 @@ HC_HASS_TO_HOMEKIT_FAN_STATE = { HEAT_COOL_DEADBAND = 5 +def _hk_hvac_mode_from_state(state: State) -> int | None: + """Return the equivalent HomeKit HVAC mode for a given state.""" + if not (hvac_mode := try_parse_enum(HVACMode, state.state)): + _LOGGER.error( + "%s: Received invalid HVAC mode: %s", state.entity_id, state.state + ) + return None + return HC_HASS_TO_HOMEKIT.get(hvac_mode) + + @TYPES.register("Thermostat") class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._unit = self.hass.config.units.temperature_unit - self.hc_homekit_to_hass = None - self.hc_hass_to_homekit = None - hc_min_temp, hc_max_temp = self.get_temperature_range() + state = self.hass.states.get(self.entity_id) + assert state + hc_min_temp, hc_max_temp = self.get_temperature_range(state) self._reload_on_change_attrs.extend( ( ATTR_MIN_HUMIDITY, @@ -185,9 +197,9 @@ class Thermostat(HomeAccessory): ) # Add additional characteristics if auto mode is supported - self.chars = [] - self.fan_chars = [] - state: State = self.hass.states.get(self.entity_id) + self.chars: list[str] = [] + self.fan_chars: list[str] = [] + attributes = state.attributes min_humidity = attributes.get(ATTR_MIN_HUMIDITY, DEFAULT_MIN_HUMIDITY) features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) @@ -285,8 +297,8 @@ class Thermostat(HomeAccessory): CHAR_CURRENT_HUMIDITY, value=50 ) - fan_modes = {} - self.ordered_fan_speeds = [] + fan_modes: dict[str, str] = {} + self.ordered_fan_speeds: list[str] = [] if features & ClimateEntityFeature.FAN_MODE: fan_modes = { @@ -358,13 +370,13 @@ class Thermostat(HomeAccessory): serv_thermostat.setter_callback = self._set_chars - def _set_fan_swing_mode(self, swing_on) -> None: + def _set_fan_swing_mode(self, swing_on: int) -> None: _LOGGER.debug("%s: Set swing mode to %s", self.entity_id, swing_on) mode = self.swing_on_mode if swing_on else SWING_OFF params = {ATTR_ENTITY_ID: self.entity_id, ATTR_SWING_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_SWING_MODE, params) - def _set_fan_speed(self, speed) -> None: + def _set_fan_speed(self, speed: int) -> None: _LOGGER.debug("%s: Set fan speed to %s", self.entity_id, speed) mode = percentage_to_ordered_list_item(self.ordered_fan_speeds, speed - 1) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} @@ -375,7 +387,7 @@ class Thermostat(HomeAccessory): return percentage_to_ordered_list_item(self.ordered_fan_speeds, 50) return self.fan_modes[FAN_ON] - def _set_fan_active(self, active) -> None: + def _set_fan_active(self, active: int) -> None: _LOGGER.debug("%s: Set fan active to %s", self.entity_id, active) if FAN_OFF not in self.fan_modes: _LOGGER.debug( @@ -388,28 +400,27 @@ class Thermostat(HomeAccessory): params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) - def _set_fan_auto(self, auto) -> None: + def _set_fan_auto(self, auto: int) -> None: _LOGGER.debug("%s: Set fan auto to %s", self.entity_id, auto) mode = self.fan_modes[FAN_AUTO] if auto else self._get_on_mode() params = {ATTR_ENTITY_ID: self.entity_id, ATTR_FAN_MODE: mode} self.async_call_service(DOMAIN_CLIMATE, SERVICE_SET_FAN_MODE, params) - def _temperature_to_homekit(self, temp): + def _temperature_to_homekit(self, temp: float | int) -> float: return temperature_to_homekit(temp, self._unit) - def _temperature_to_states(self, temp): + def _temperature_to_states(self, temp: float | int) -> float: return temperature_to_states(temp, self._unit) - def _set_chars(self, char_values): + def _set_chars(self, char_values: dict[str, Any]) -> None: _LOGGER.debug("Thermostat _set_chars: %s", char_values) events = [] - params = {ATTR_ENTITY_ID: self.entity_id} + params: dict[str, Any] = {ATTR_ENTITY_ID: self.entity_id} service = None state = self.hass.states.get(self.entity_id) + assert state features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - hvac_mode = state.state - homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + homekit_hvac_mode = _hk_hvac_mode_from_state(state) # Homekit will reset the mode when VIEWING the temp # Ignore it if its the same mode if ( @@ -493,10 +504,12 @@ class Thermostat(HomeAccessory): CHAR_HEATING_THRESHOLD_TEMPERATURE in char_values or CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values ): + assert self.char_cooling_thresh_temp + assert self.char_heating_thresh_temp service = SERVICE_SET_TEMPERATURE_THERMOSTAT high = self.char_cooling_thresh_temp.value low = self.char_heating_thresh_temp.value - min_temp, max_temp = self.get_temperature_range() + min_temp, max_temp = self.get_temperature_range(state) if CHAR_COOLING_THRESHOLD_TEMPERATURE in char_values: events.append( f"{CHAR_COOLING_THRESHOLD_TEMPERATURE} to" @@ -539,7 +552,7 @@ class Thermostat(HomeAccessory): if CHAR_TARGET_HUMIDITY in char_values: self.set_target_humidity(char_values[CHAR_TARGET_HUMIDITY]) - def _configure_hvac_modes(self, state): + def _configure_hvac_modes(self, state: State) -> None: """Configure target mode characteristics.""" # This cannot be none OR an empty list hc_modes = state.attributes.get(ATTR_HVAC_MODES) or DEFAULT_HVAC_MODES @@ -567,16 +580,16 @@ class Thermostat(HomeAccessory): } self.hc_hass_to_homekit = {k: v for v, k in self.hc_homekit_to_hass.items()} - def get_temperature_range(self): + def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" return _get_temperature_range_from_state( - self.hass.states.get(self.entity_id), + state, self._unit, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP, ) - def set_target_humidity(self, value): + def set_target_humidity(self, value: float) -> None: """Set target humidity to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target humidity to %d", self.entity_id, value) params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HUMIDITY: value} @@ -585,15 +598,13 @@ class Thermostat(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" attributes = new_state.attributes features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) # Update target operation mode FIRST - hvac_mode = new_state.state - if hvac_mode and hvac_mode in HC_HASS_TO_HOMEKIT: - homekit_hvac_mode = HC_HASS_TO_HOMEKIT[hvac_mode] + if (homekit_hvac_mode := _hk_hvac_mode_from_state(new_state)) is not None: if homekit_hvac_mode in self.hc_homekit_to_hass: self.char_target_heat_cool.set_value(homekit_hvac_mode) else: @@ -602,7 +613,7 @@ class Thermostat(HomeAccessory): "Cannot map hvac target mode: %s to homekit as only %s modes" " are supported" ), - hvac_mode, + new_state.state, self.hc_homekit_to_hass, ) @@ -618,12 +629,14 @@ class Thermostat(HomeAccessory): # Update current humidity if CHAR_CURRENT_HUMIDITY in self.chars: + assert self.char_current_humidity current_humdity = attributes.get(ATTR_CURRENT_HUMIDITY) if isinstance(current_humdity, (int, float)): self.char_current_humidity.set_value(current_humdity) # Update target humidity if CHAR_TARGET_HUMIDITY in self.chars: + assert self.char_target_humidity target_humdity = attributes.get(ATTR_HUMIDITY) if isinstance(target_humdity, (int, float)): self.char_target_humidity.set_value(target_humdity) @@ -671,7 +684,7 @@ class Thermostat(HomeAccessory): self._async_update_fan_state(new_state) @callback - def _async_update_fan_state(self, new_state): + def _async_update_fan_state(self, new_state: State) -> None: """Update state without rechecking the device features.""" attributes = new_state.attributes @@ -710,7 +723,7 @@ class Thermostat(HomeAccessory): class WaterHeater(HomeAccessory): """Generate a WaterHeater accessory for a water_heater.""" - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a WaterHeater accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) self._reload_on_change_attrs.extend( @@ -720,7 +733,9 @@ class WaterHeater(HomeAccessory): ) ) self._unit = self.hass.config.units.temperature_unit - min_temp, max_temp = self.get_temperature_range() + state = self.hass.states.get(self.entity_id) + assert state + min_temp, max_temp = self.get_temperature_range(state) serv_thermostat = self.add_preload_service(SERV_THERMOSTAT) @@ -751,25 +766,24 @@ class WaterHeater(HomeAccessory): CHAR_TEMP_DISPLAY_UNITS, value=0 ) - state = self.hass.states.get(self.entity_id) self.async_update_state(state) - def get_temperature_range(self): + def get_temperature_range(self, state: State) -> tuple[float, float]: """Return min and max temperature range.""" return _get_temperature_range_from_state( - self.hass.states.get(self.entity_id), + state, self._unit, DEFAULT_MIN_TEMP_WATER_HEATER, DEFAULT_MAX_TEMP_WATER_HEATER, ) - def set_heat_cool(self, value): + def set_heat_cool(self, value: int) -> None: """Change operation mode to value if call came from HomeKit.""" _LOGGER.debug("%s: Set heat-cool to %d", self.entity_id, value) if HC_HOMEKIT_TO_HASS[value] != HVACMode.HEAT: self.char_target_heat_cool.set_value(1) # Heat - def set_target_temperature(self, value): + def set_target_temperature(self, value: float) -> None: """Set target temperature to value if call came from HomeKit.""" _LOGGER.debug("%s: Set target temperature to %.1f°C", self.entity_id, value) temperature = temperature_to_states(value, self._unit) @@ -782,7 +796,7 @@ class WaterHeater(HomeAccessory): ) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update water_heater state after state change.""" # Update current and target temperature target_temperature = _get_target_temperature(new_state, self._unit) @@ -803,7 +817,9 @@ class WaterHeater(HomeAccessory): self.char_target_heat_cool.set_value(1) # Heat -def _get_temperature_range_from_state(state, unit, default_min, default_max): +def _get_temperature_range_from_state( + state: State, unit: str, default_min: float, default_max: float +) -> tuple[float, float]: """Calculate the temperature range from a state.""" if min_temp := state.attributes.get(ATTR_MIN_TEMP): min_temp = round(temperature_to_homekit(min_temp, unit) * 2) / 2 @@ -825,7 +841,7 @@ def _get_temperature_range_from_state(state, unit, default_min, default_max): return min_temp, max_temp -def _get_target_temperature(state, unit): +def _get_target_temperature(state: State, unit: str) -> float | None: """Calculate the target temperature from a state.""" target_temp = state.attributes.get(ATTR_TEMPERATURE) if isinstance(target_temp, (int, float)): @@ -833,7 +849,7 @@ def _get_target_temperature(state, unit): return None -def _get_current_temperature(state, unit): +def _get_current_temperature(state: State, unit: str) -> float | None: """Calculate the current temperature from a state.""" target_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(target_temp, (int, float)): diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index a4ce765d795..1c3fb0914f3 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -106,7 +106,9 @@ async def test_thermostat(hass: HomeAssistant, hk_driver, events) -> None: assert acc.aid == 1 assert acc.category == 9 # Thermostat - assert acc.get_temperature_range() == (7.0, 35.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7.0, 35.0) assert acc.char_current_heat_cool.value == 0 assert acc.char_target_heat_cool.value == 0 assert acc.char_current_temp.value == 21.0 @@ -841,7 +843,9 @@ async def test_thermostat_fahrenheit(hass: HomeAssistant, hk_driver, events) -> }, ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (7.0, 35.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7.0, 35.0) assert acc.char_heating_thresh_temp.value == 20.1 assert acc.char_cooling_thresh_temp.value == 24.0 assert acc.char_current_temp.value == 23.0 @@ -929,14 +933,18 @@ async def test_thermostat_get_temperature_range(hass: HomeAssistant, hk_driver) entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (20, 25) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (20, 25) acc._unit = UnitOfTemperature.FAHRENHEIT hass.states.async_set( entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70} ) await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.5, 21.0) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (15.5, 21.0) async def test_thermostat_temperature_step_whole( @@ -982,9 +990,14 @@ async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> Non hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = Thermostat(hass, hk_driver, "Climate", "climate.simple", 2, None) + entity_id = "climate.simple" + hass.states.async_set(entity_id, HVACMode.OFF) + + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 2, None) assert acc.category == 9 - assert acc.get_temperature_range() == (7, 35) + state = hass.states.get(entity_id) + assert state + assert acc.get_temperature_range(state) == (7, 35) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { "cool", "heat", @@ -992,9 +1005,13 @@ async def test_thermostat_restore(hass: HomeAssistant, hk_driver, events) -> Non "off", } - acc = Thermostat(hass, hk_driver, "Climate", "climate.all_info_set", 3, None) + entity_id = "climate.all_info_set" + state = hass.states.get(entity_id) + assert state + + acc = Thermostat(hass, hk_driver, "Climate", entity_id, 3, None) assert acc.category == 9 - assert acc.get_temperature_range() == (60.0, 70.0) + assert acc.get_temperature_range(state) == (60.0, 70.0) assert set(acc.char_target_heat_cool.properties["ValidValues"].keys()) == { "heat_cool", "off", @@ -1762,15 +1779,19 @@ async def test_water_heater_get_temperature_range( hass.states.async_set( entity_id, HVACMode.HEAT, {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25} ) + state = hass.states.get(entity_id) + assert state await hass.async_block_till_done() - assert acc.get_temperature_range() == (20, 25) + assert acc.get_temperature_range(state) == (20, 25) acc._unit = UnitOfTemperature.FAHRENHEIT hass.states.async_set( entity_id, HVACMode.OFF, {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70} ) + state = hass.states.get(entity_id) + assert state await hass.async_block_till_done() - assert acc.get_temperature_range() == (15.5, 21.0) + assert acc.get_temperature_range(state) == (15.5, 21.0) async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> None: @@ -1795,20 +1816,27 @@ async def test_water_heater_restore(hass: HomeAssistant, hk_driver, events) -> N hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() - acc = Thermostat(hass, hk_driver, "WaterHeater", "water_heater.simple", 2, None) + entity_id = "water_heater.simple" + hass.states.async_set(entity_id, "off") + state = hass.states.get(entity_id) + assert state + + acc = Thermostat(hass, hk_driver, "WaterHeater", entity_id, 2, None) assert acc.category == 9 - assert acc.get_temperature_range() == (7, 35) + assert acc.get_temperature_range(state) == (7, 35) assert set(acc.char_current_heat_cool.properties["ValidValues"].keys()) == { "Cool", "Heat", "Off", } - acc = WaterHeater( - hass, hk_driver, "WaterHeater", "water_heater.all_info_set", 3, None - ) + entity_id = "water_heater.all_info_set" + state = hass.states.get(entity_id) + assert state + + acc = WaterHeater(hass, hk_driver, "WaterHeater", entity_id, 3, None) assert acc.category == 9 - assert acc.get_temperature_range() == (60.0, 70.0) + assert acc.get_temperature_range(state) == (60.0, 70.0) assert set(acc.char_current_heat_cool.properties["ValidValues"].keys()) == { "Cool", "Heat", From 5ed8de83484c88f38bfe3b27692500b992e3f0e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 14:45:59 -1000 Subject: [PATCH 435/968] Enable strict typing in HomeKit (#101968) --- .strict-typing | 10 +-- .../components/homekit/type_cameras.py | 7 +- .../components/homekit/type_covers.py | 13 +-- .../components/homekit/type_remotes.py | 8 +- mypy.ini | 82 +------------------ 5 files changed, 17 insertions(+), 103 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4aa0a44c96d..2adadffea49 100644 --- a/.strict-typing +++ b/.strict-typing @@ -157,15 +157,7 @@ homeassistant.components.homeassistant_green.* homeassistant.components.homeassistant_hardware.* homeassistant.components.homeassistant_sky_connect.* homeassistant.components.homeassistant_yellow.* -homeassistant.components.homekit -homeassistant.components.homekit.accessories -homeassistant.components.homekit.aidmanager -homeassistant.components.homekit.config_flow -homeassistant.components.homekit.diagnostics -homeassistant.components.homekit.logbook -homeassistant.components.homekit.type_locks -homeassistant.components.homekit.type_triggers -homeassistant.components.homekit.util +homeassistant.components.homekit.* homeassistant.components.homekit_controller homeassistant.components.homekit_controller.alarm_control_panel homeassistant.components.homekit_controller.button diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 6bc8e785c7f..ed26265be24 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -139,7 +139,7 @@ CONFIG_DEFAULTS = { @TYPES.register("Camera") -class Camera(HomeAccessory, PyhapCamera): +class Camera(HomeAccessory, PyhapCamera): # type: ignore[misc] """Generate a Camera accessory.""" def __init__( @@ -337,7 +337,8 @@ class Camera(HomeAccessory, PyhapCamera): async def _async_get_stream_source(self) -> str | None: """Find the camera stream source url.""" - if stream_source := self.config.get(CONF_STREAM_SOURCE): + stream_source: str | None = self.config.get(CONF_STREAM_SOURCE) + if stream_source: return stream_source try: stream_source = await camera.async_get_stream_source( @@ -419,7 +420,7 @@ class Camera(HomeAccessory, PyhapCamera): stderr_reader = await stream.get_reader(source=FFMPEG_STDERR) - async def watch_session(_): + async def watch_session(_: Any) -> None: await self._async_ffmpeg_watch(session_info["id"]) session_info[FFMPEG_LOGGER] = asyncio.create_task( diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index c8599b99664..1d60d405502 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -104,6 +104,7 @@ class GarageDoorOpener(HomeAccessory): """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) state = self.hass.states.get(self.entity_id) + assert state serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER) self.char_current_state = serv_garage_door.configure_char( @@ -124,7 +125,7 @@ class GarageDoorOpener(HomeAccessory): self.async_update_state(state) - async def run(self): + async def run(self) -> None: """Handle accessory driver started event. Run inside the Home Assistant event loop. @@ -165,7 +166,7 @@ class GarageDoorOpener(HomeAccessory): detected, ) - def set_state(self, value): + def set_state(self, value: int) -> None: """Change garage state if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) @@ -180,7 +181,7 @@ class GarageDoorOpener(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_CLOSE_COVER, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update cover state after state changed.""" hass_state = new_state.state target_door_state = DOOR_TARGET_HASS_TO_HK.get(hass_state) @@ -235,7 +236,7 @@ class OpeningDeviceBase(HomeAccessory): CHAR_CURRENT_TILT_ANGLE, value=0 ) - def set_stop(self, value): + def set_stop(self, value: int) -> None: """Stop the cover motion from HomeKit.""" if value != 1: return @@ -243,7 +244,7 @@ class OpeningDeviceBase(HomeAccessory): DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: self.entity_id} ) - def set_tilt(self, value): + def set_tilt(self, value: float) -> None: """Set tilt to value if call came from HomeKit.""" _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) @@ -256,7 +257,7 @@ class OpeningDeviceBase(HomeAccessory): self.async_call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update cover position and tilt after state changed.""" # update tilt if not self._supports_tilt: diff --git a/homeassistant/components/homekit/type_remotes.py b/homeassistant/components/homekit/type_remotes.py index 0f6e22abe1d..e03b14f943a 100644 --- a/homeassistant/components/homekit/type_remotes.py +++ b/homeassistant/components/homekit/type_remotes.py @@ -165,19 +165,19 @@ class RemoteInputSelectAccessory(HomeAccessory, ABC): return list(self._get_mapped_sources(state)) @abstractmethod - def set_on_off(self, value): + def set_on_off(self, value: bool) -> None: """Move switch state to value if call came from HomeKit.""" @abstractmethod - def set_input_source(self, value): + def set_input_source(self, value: int) -> None: """Send input set value if call came from HomeKit.""" @abstractmethod - def set_remote_key(self, value): + def set_remote_key(self, value: int) -> None: """Send remote key value if call came from HomeKit.""" @callback - def _async_update_input_state(self, hk_state, new_state): + def _async_update_input_state(self, hk_state: int, new_state: State) -> None: """Update input state after state changed.""" # Set active input if not self.support_select_source or not self.sources: diff --git a/mypy.ini b/mypy.ini index 40d57a6b430..93fe5326e98 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1331,87 +1331,7 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.homekit] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.accessories] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.aidmanager] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.config_flow] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.diagnostics] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.logbook] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.type_locks] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.type_triggers] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -warn_return_any = true -warn_unreachable = true - -[mypy-homeassistant.components.homekit.util] +[mypy-homeassistant.components.homekit.*] check_untyped_defs = true disallow_incomplete_defs = true disallow_subclassing_any = true From 89d86fe983600ae73329f4993c358f8edc4f143b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Oct 2023 23:48:59 -1000 Subject: [PATCH 436/968] Bump aioesphomeapi to 17.2.0 (#101981) * Bump aioesphomeapi to 17.2.0 changelog: https://github.com/esphome/aioesphomeapi/compare/v17.1.5...v17.2.0 * fix import from wrong module --- homeassistant/components/esphome/bluetooth/client.py | 7 +++++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index 411a5b989a3..d44d331248b 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -25,8 +25,11 @@ from aioesphomeapi import ( BluetoothProxyFeature, DeviceInfo, ) -from aioesphomeapi.connection import APIConnectionError, TimeoutAPIError -from aioesphomeapi.core import BluetoothGATTAPIError +from aioesphomeapi.core import ( + APIConnectionError, + BluetoothGATTAPIError, + TimeoutAPIError, +) from async_interrupt import interrupt from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.client import BaseBleakClient, NotifyCallback diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 82567d7310b..8a2ede93b3e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.1.5", + "aioesphomeapi==17.2.0", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index b20c830f9a9..b1759ec6479 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.1.5 +aioesphomeapi==17.2.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a529492f1e..c745e407561 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.1.5 +aioesphomeapi==17.2.0 # homeassistant.components.flo aioflo==2021.11.0 From 8a4fe5add1655e6bb0f47cb5a9c7d19b693a5459 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 14 Oct 2023 11:52:35 +0200 Subject: [PATCH 437/968] Use aiowithings (#101819) * Subscribe to Withings webhooks outside of coordinator * Subscribe to Withings webhooks outside of coordinator * Split Withings coordinator * Split Withings coordinator * Use aiowithings * Use aiowithings * Use aiowithings * Update homeassistant/components/withings/sensor.py * Merge * Remove startdate * Minor fixes * Bump to 0.4.1 * Fix snapshot * Fix datapoint * Bump to 0.4.2 * Bump to 0.4.3 * Bump to 0.4.4 --- .coveragerc | 1 - homeassistant/components/withings/__init__.py | 74 +++-- homeassistant/components/withings/api.py | 170 ---------- .../withings/application_credentials.py | 6 +- .../components/withings/config_flow.py | 10 +- homeassistant/components/withings/const.py | 40 --- .../components/withings/coordinator.py | 217 ++++--------- .../components/withings/manifest.json | 2 +- homeassistant/components/withings/sensor.py | 306 +++++++++--------- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/withings/conftest.py | 49 +-- .../withings/fixtures/empty_notify_list.json | 3 - .../withings/snapshots/test_sensor.ambr | 48 +-- .../components/withings/test_binary_sensor.py | 8 +- tests/components/withings/test_init.py | 73 +++-- tests/components/withings/test_sensor.py | 2 +- 17 files changed, 359 insertions(+), 662 deletions(-) delete mode 100644 homeassistant/components/withings/api.py delete mode 100644 tests/components/withings/fixtures/empty_notify_list.json diff --git a/.coveragerc b/.coveragerc index 41b46796373..74729b059f1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1514,7 +1514,6 @@ omit = homeassistant/components/wiffi/sensor.py homeassistant/components/wiffi/wiffi_strings.py homeassistant/components/wirelesstag/* - homeassistant/components/withings/api.py homeassistant/components/wolflink/__init__.py homeassistant/components/wolflink/sensor.py homeassistant/components/worldtidesinfo/sensor.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index a17dffd22e8..05f2db4b18d 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -12,8 +12,9 @@ from typing import Any from aiohttp.hdrs import METH_HEAD, METH_POST from aiohttp.web import Request, Response +from aiowithings import NotificationCategory, WithingsClient +from aiowithings.util import to_enum import voluptuous as vol -from withings_api.common import NotifyAppli from homeassistant.components import cloud from homeassistant.components.application_credentials import ( @@ -29,6 +30,7 @@ from homeassistant.components.webhook import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_ACCESS_TOKEN, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_TOKEN, @@ -37,12 +39,16 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .api import ConfigEntryWithingsApi from .const import ( BED_PRESENCE_COORDINATOR, CONF_PROFILES, @@ -134,14 +140,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_update_entry( entry, data=new_data, unique_id=unique_id ) + session = async_get_clientsession(hass) + client = WithingsClient(session=session) + implementation = await async_get_config_entry_implementation(hass, entry) + oauth_session = OAuth2Session(hass, entry, implementation) - client = ConfigEntryWithingsApi( - hass=hass, - config_entry=entry, - implementation=await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ), - ) + async def _refresh_token() -> str: + await oauth_session.async_ensure_token_valid() + return oauth_session.token[CONF_ACCESS_TOKEN] + + client.refresh_token_function = _refresh_token coordinators: dict[str, WithingsDataUpdateCoordinator] = { MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client), SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client), @@ -230,19 +238,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def async_subscribe_webhooks( - client: ConfigEntryWithingsApi, webhook_url: str -) -> None: +async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None: """Subscribe to Withings webhooks.""" await async_unsubscribe_webhooks(client) notification_to_subscribe = { - NotifyAppli.WEIGHT, - NotifyAppli.CIRCULATORY, - NotifyAppli.ACTIVITY, - NotifyAppli.SLEEP, - NotifyAppli.BED_IN, - NotifyAppli.BED_OUT, + NotificationCategory.WEIGHT, + NotificationCategory.PRESSURE, + NotificationCategory.ACTIVITY, + NotificationCategory.SLEEP, + NotificationCategory.IN_BED, + NotificationCategory.OUT_BED, } for notification in notification_to_subscribe: @@ -255,25 +261,26 @@ async def async_subscribe_webhooks( # Withings will HTTP HEAD the callback_url and needs some downtime # between each call or there is a higher chance of failure. await asyncio.sleep(SUBSCRIBE_DELAY.total_seconds()) - await client.async_notify_subscribe(webhook_url, notification) + await client.subscribe_notification(webhook_url, notification) -async def async_unsubscribe_webhooks(client: ConfigEntryWithingsApi) -> None: +async def async_unsubscribe_webhooks(client: WithingsClient) -> None: """Unsubscribe to all Withings webhooks.""" - current_webhooks = await client.async_notify_list() + current_webhooks = await client.list_notification_configurations() - for webhook_configuration in current_webhooks.profiles: + for webhook_configuration in current_webhooks: LOGGER.debug( "Unsubscribing %s for %s in %s seconds", - webhook_configuration.callbackurl, - webhook_configuration.appli, + webhook_configuration.callback_url, + webhook_configuration.notification_category, UNSUBSCRIBE_DELAY.total_seconds(), ) # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. await asyncio.sleep(UNSUBSCRIBE_DELAY.total_seconds()) - await client.async_notify_revoke( - webhook_configuration.callbackurl, webhook_configuration.appli + await client.revoke_notification_configurations( + webhook_configuration.callback_url, + webhook_configuration.notification_category, ) @@ -336,14 +343,15 @@ def get_webhook_handler( "Parameter appli not provided", message_code=20 ) - try: - appli = NotifyAppli(int(params.getone("appli"))) # type: ignore[arg-type] - except ValueError: - return json_message_response("Invalid appli provided", message_code=21) + notification_category = to_enum( + NotificationCategory, + int(params.getone("appli")), # type: ignore[arg-type] + NotificationCategory.UNKNOWN, + ) for coordinator in coordinators.values(): - if appli in coordinator.notification_categories: - await coordinator.async_webhook_data_updated(appli) + if notification_category in coordinator.notification_categories: + await coordinator.async_webhook_data_updated(notification_category) return json_message_response("Success", message_code=0) diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py deleted file mode 100644 index f9739d3fb6f..00000000000 --- a/homeassistant/components/withings/api.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Api for Withings.""" -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable, Iterable -from typing import Any - -import arrow -import requests -from withings_api import AbstractWithingsApi, DateType -from withings_api.common import ( - GetSleepSummaryField, - MeasureGetMeasGroupCategory, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, -) - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) - -from .const import LOGGER - -_RETRY_COEFFICIENT = 0.5 - - -class ConfigEntryWithingsApi(AbstractWithingsApi): - """Withing API that uses HA resources.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - implementation: AbstractOAuth2Implementation, - ) -> None: - """Initialize object.""" - self._hass = hass - self.config_entry = config_entry - self._implementation = implementation - self.session = OAuth2Session(hass, config_entry, implementation) - - def _request( - self, path: str, params: dict[str, Any], method: str = "GET" - ) -> dict[str, Any]: - """Perform an async request.""" - asyncio.run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self._hass.loop - ).result() - - access_token = self.config_entry.data["token"]["access_token"] - response = requests.request( - method, - f"{self.URL}/{path}", - params=params, - headers={"Authorization": f"Bearer {access_token}"}, - timeout=10, - ) - return response.json() - - async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any: - """Retry a function call. - - Withings' API occasionally and incorrectly throws errors. - Retrying the call tends to work. - """ - exception = None - for attempt in range(1, attempts + 1): - LOGGER.debug("Attempt %s of %s", attempt, attempts) - try: - return await func() - except Exception as exception1: # pylint: disable=broad-except - LOGGER.debug( - "Failed attempt %s of %s (%s)", attempt, attempts, exception1 - ) - # Make each backoff pause a little bit longer - await asyncio.sleep(_RETRY_COEFFICIENT * attempt) - exception = exception1 - continue - - if exception: - raise exception - - async def async_measure_get_meas( - self, - meastype: MeasureType | None = None, - category: MeasureGetMeasGroupCategory | None = None, - startdate: DateType | None = arrow.utcnow(), - enddate: DateType | None = arrow.utcnow(), - offset: int | None = None, - lastupdate: DateType | None = arrow.utcnow(), - ) -> MeasureGetMeasResponse: - """Get measurements.""" - - async def call_super() -> MeasureGetMeasResponse: - return await self._hass.async_add_executor_job( - self.measure_get_meas, - meastype, - category, - startdate, - enddate, - offset, - lastupdate, - ) - - return await self._do_retry(call_super) - - async def async_sleep_get_summary( - self, - data_fields: Iterable[GetSleepSummaryField], - startdateymd: DateType | None = arrow.utcnow(), - enddateymd: DateType | None = arrow.utcnow(), - offset: int | None = None, - lastupdate: DateType | None = arrow.utcnow(), - ) -> SleepGetSummaryResponse: - """Get sleep data.""" - - async def call_super() -> SleepGetSummaryResponse: - return await self._hass.async_add_executor_job( - self.sleep_get_summary, - data_fields, - startdateymd, - enddateymd, - offset, - lastupdate, - ) - - return await self._do_retry(call_super) - - async def async_notify_list( - self, appli: NotifyAppli | None = None - ) -> NotifyListResponse: - """List webhooks.""" - - async def call_super() -> NotifyListResponse: - return await self._hass.async_add_executor_job(self.notify_list, appli) - - return await self._do_retry(call_super) - - async def async_notify_subscribe( - self, - callbackurl: str, - appli: NotifyAppli | None = None, - comment: str | None = None, - ) -> None: - """Subscribe to webhook.""" - - async def call_super() -> None: - await self._hass.async_add_executor_job( - self.notify_subscribe, callbackurl, appli, comment - ) - - await self._do_retry(call_super) - - async def async_notify_revoke( - self, callbackurl: str | None = None, appli: NotifyAppli | None = None - ) -> None: - """Revoke webhook.""" - - async def call_super() -> None: - await self._hass.async_add_executor_job( - self.notify_revoke, callbackurl, appli - ) - - await self._do_retry(call_super) diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index 1d5b52466c4..ce96ed782dd 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -2,7 +2,7 @@ from typing import Any -from withings_api import AbstractWithingsApi, WithingsAuth +from aiowithings import AUTHORIZATION_URL, TOKEN_URL from homeassistant.components.application_credentials import ( AuthImplementation, @@ -24,8 +24,8 @@ async def async_get_auth_implementation( DOMAIN, credential, authorization_server=AuthorizationServer( - authorize_url=f"{WithingsAuth.URL}/oauth2_user/authorize2", - token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", + authorize_url=AUTHORIZATION_URL, + token_url=TOKEN_URL, ), ) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 8cab297b96a..31c40bf9791 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from withings_api.common import AuthScope +from aiowithings import AuthScope from homeassistant.components.webhook import async_generate_id from homeassistant.config_entries import ConfigEntry @@ -36,10 +36,10 @@ class WithingsFlowHandler( return { "scope": ",".join( [ - AuthScope.USER_INFO.value, - AuthScope.USER_METRICS.value, - AuthScope.USER_ACTIVITY.value, - AuthScope.USER_SLEEP_EVENTS.value, + AuthScope.USER_INFO, + AuthScope.USER_METRICS, + AuthScope.USER_ACTIVITY, + AuthScope.USER_SLEEP_EVENTS, ] ) } diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index bc3e26765a4..4eeaa56c76d 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,5 +1,4 @@ """Constants used by the Withings component.""" -from enum import StrEnum import logging DEFAULT_TITLE = "Withings" @@ -21,45 +20,6 @@ BED_PRESENCE_COORDINATOR = "bed_presence_coordinator" LOGGER = logging.getLogger(__package__) -class Measurement(StrEnum): - """Measurement supported by the withings integration.""" - - BODY_TEMP_C = "body_temperature_c" - BONE_MASS_KG = "bone_mass_kg" - DIASTOLIC_MMHG = "diastolic_blood_pressure_mmhg" - FAT_FREE_MASS_KG = "fat_free_mass_kg" - FAT_MASS_KG = "fat_mass_kg" - FAT_RATIO_PCT = "fat_ratio_pct" - HEART_PULSE_BPM = "heart_pulse_bpm" - HEIGHT_M = "height_m" - HYDRATION = "hydration" - IN_BED = "in_bed" - MUSCLE_MASS_KG = "muscle_mass_kg" - PWV = "pulse_wave_velocity" - SKIN_TEMP_C = "skin_temperature_c" - SLEEP_BREATHING_DISTURBANCES_INTENSITY = "sleep_breathing_disturbances_intensity" - SLEEP_DEEP_DURATION_SECONDS = "sleep_deep_duration_seconds" - SLEEP_HEART_RATE_AVERAGE = "sleep_heart_rate_average_bpm" - SLEEP_HEART_RATE_MAX = "sleep_heart_rate_max_bpm" - SLEEP_HEART_RATE_MIN = "sleep_heart_rate_min_bpm" - SLEEP_LIGHT_DURATION_SECONDS = "sleep_light_duration_seconds" - SLEEP_REM_DURATION_SECONDS = "sleep_rem_duration_seconds" - SLEEP_RESPIRATORY_RATE_AVERAGE = "sleep_respiratory_average_bpm" - SLEEP_RESPIRATORY_RATE_MAX = "sleep_respiratory_max_bpm" - SLEEP_RESPIRATORY_RATE_MIN = "sleep_respiratory_min_bpm" - SLEEP_SCORE = "sleep_score" - SLEEP_SNORING = "sleep_snoring" - SLEEP_SNORING_EPISODE_COUNT = "sleep_snoring_eposode_count" - SLEEP_TOSLEEP_DURATION_SECONDS = "sleep_tosleep_duration_seconds" - SLEEP_TOWAKEUP_DURATION_SECONDS = "sleep_towakeup_duration_seconds" - SLEEP_WAKEUP_COUNT = "sleep_wakeup_count" - SLEEP_WAKEUP_DURATION_SECONDS = "sleep_wakeup_duration_seconds" - SPO2_PCT = "spo2_pct" - SYSTOLIC_MMGH = "systolic_blood_pressure_mmhg" - TEMP_C = "temperature_c" - WEIGHT_KG = "weight_kg" - - SCORE_POINTS = "points" UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = "br/min" diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index f5963ad6ebf..b87cb550a13 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,19 +1,19 @@ """Withings coordinator.""" from abc import abstractmethod -from collections.abc import Callable from datetime import timedelta -from typing import Any, TypeVar +from typing import TypeVar -from withings_api.common import ( - AuthFailedException, - GetSleepSummaryField, - MeasureGroupAttribs, - MeasureType, - MeasureTypes, - NotifyAppli, - UnauthorizedException, - query_measure_groups, +from aiowithings import ( + MeasurementType, + NotificationCategory, + SleepSummary, + SleepSummaryDataFields, + WithingsAuthenticationFailedError, + WithingsClient, + WithingsUnauthorizedError, + aggregate_measurements, ) +from aiowithings.helpers import aggregate_sleep_summary from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -21,51 +21,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util -from .api import ConfigEntryWithingsApi -from .const import LOGGER, Measurement - -WITHINGS_MEASURE_TYPE_MAP: dict[ - NotifyAppli | GetSleepSummaryField | MeasureType, Measurement -] = { - MeasureType.WEIGHT: Measurement.WEIGHT_KG, - MeasureType.FAT_MASS_WEIGHT: Measurement.FAT_MASS_KG, - MeasureType.FAT_FREE_MASS: Measurement.FAT_FREE_MASS_KG, - MeasureType.MUSCLE_MASS: Measurement.MUSCLE_MASS_KG, - MeasureType.BONE_MASS: Measurement.BONE_MASS_KG, - MeasureType.HEIGHT: Measurement.HEIGHT_M, - MeasureType.TEMPERATURE: Measurement.TEMP_C, - MeasureType.BODY_TEMPERATURE: Measurement.BODY_TEMP_C, - MeasureType.SKIN_TEMPERATURE: Measurement.SKIN_TEMP_C, - MeasureType.FAT_RATIO: Measurement.FAT_RATIO_PCT, - MeasureType.DIASTOLIC_BLOOD_PRESSURE: Measurement.DIASTOLIC_MMHG, - MeasureType.SYSTOLIC_BLOOD_PRESSURE: Measurement.SYSTOLIC_MMGH, - MeasureType.HEART_RATE: Measurement.HEART_PULSE_BPM, - MeasureType.SP02: Measurement.SPO2_PCT, - MeasureType.HYDRATION: Measurement.HYDRATION, - MeasureType.PULSE_WAVE_VELOCITY: Measurement.PWV, - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY: ( - Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY - ), - GetSleepSummaryField.DEEP_SLEEP_DURATION: Measurement.SLEEP_DEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_SLEEP: Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - GetSleepSummaryField.DURATION_TO_WAKEUP: ( - Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS - ), - GetSleepSummaryField.HR_AVERAGE: Measurement.SLEEP_HEART_RATE_AVERAGE, - GetSleepSummaryField.HR_MAX: Measurement.SLEEP_HEART_RATE_MAX, - GetSleepSummaryField.HR_MIN: Measurement.SLEEP_HEART_RATE_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION: Measurement.SLEEP_LIGHT_DURATION_SECONDS, - GetSleepSummaryField.REM_SLEEP_DURATION: Measurement.SLEEP_REM_DURATION_SECONDS, - GetSleepSummaryField.RR_AVERAGE: Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - GetSleepSummaryField.RR_MAX: Measurement.SLEEP_RESPIRATORY_RATE_MAX, - GetSleepSummaryField.RR_MIN: Measurement.SLEEP_RESPIRATORY_RATE_MIN, - GetSleepSummaryField.SLEEP_SCORE: Measurement.SLEEP_SCORE, - GetSleepSummaryField.SNORING: Measurement.SLEEP_SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT: Measurement.SLEEP_SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT: Measurement.SLEEP_WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION: Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - NotifyAppli.BED_IN: Measurement.IN_BED, -} +from .const import LOGGER _T = TypeVar("_T") @@ -78,13 +34,13 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): config_entry: ConfigEntry _default_update_interval: timedelta | None = UPDATE_INTERVAL - def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__( hass, LOGGER, name="Withings", update_interval=self._default_update_interval ) self._client = client - self.notification_categories: set[NotifyAppli] = set() + self.notification_categories: set[NotificationCategory] = set() def webhook_subscription_listener(self, connected: bool) -> None: """Call when webhook status changed.""" @@ -94,7 +50,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): self.update_interval = self._default_update_interval async def async_webhook_data_updated( - self, notification_category: NotifyAppli + self, notification_category: NotificationCategory ) -> None: """Update data when webhook is called.""" LOGGER.debug("Withings webhook triggered for %s", notification_category) @@ -103,7 +59,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): async def _async_update_data(self) -> _T: try: return await self._internal_update_data() - except (UnauthorizedException, AuthFailedException) as exc: + except (WithingsUnauthorizedError, WithingsAuthenticationFailedError) as exc: raise ConfigEntryAuthFailed from exc @abstractmethod @@ -112,136 +68,71 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): class WithingsMeasurementDataUpdateCoordinator( - WithingsDataUpdateCoordinator[dict[Measurement, Any]] + WithingsDataUpdateCoordinator[dict[MeasurementType, float]] ): """Withings measurement coordinator.""" - def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) self.notification_categories = { - NotifyAppli.WEIGHT, - NotifyAppli.ACTIVITY, - NotifyAppli.CIRCULATORY, + NotificationCategory.WEIGHT, + NotificationCategory.ACTIVITY, + NotificationCategory.PRESSURE, } - async def _internal_update_data(self) -> dict[Measurement, Any]: + async def _internal_update_data(self) -> dict[MeasurementType, float]: """Retrieve measurement data.""" now = dt_util.utcnow() startdate = now - timedelta(days=7) - response = await self._client.async_measure_get_meas( - None, None, startdate, now, None, startdate - ) + response = await self._client.get_measurement_in_period(startdate, now) - # Sort from oldest to newest. - groups = sorted( - query_measure_groups( - response, MeasureTypes.ANY, MeasureGroupAttribs.UNAMBIGUOUS - ), - key=lambda group: group.created.datetime, - reverse=False, - ) - - return { - WITHINGS_MEASURE_TYPE_MAP[measure.type]: round( - float(measure.value * pow(10, measure.unit)), 2 - ) - for group in groups - for measure in group.measures - if measure.type in WITHINGS_MEASURE_TYPE_MAP - } + return aggregate_measurements(response) class WithingsSleepDataUpdateCoordinator( - WithingsDataUpdateCoordinator[dict[Measurement, Any]] + WithingsDataUpdateCoordinator[SleepSummary | None] ): """Withings sleep coordinator.""" - def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) self.notification_categories = { - NotifyAppli.SLEEP, + NotificationCategory.SLEEP, } - async def _internal_update_data(self) -> dict[Measurement, Any]: + async def _internal_update_data(self) -> SleepSummary | None: """Retrieve sleep data.""" now = dt_util.now() yesterday = now - timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + timedelta(hours=12) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - response = await self._client.async_sleep_get_summary( - lastupdate=yesterday_noon_utc, - data_fields=[ - GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - GetSleepSummaryField.DEEP_SLEEP_DURATION, - GetSleepSummaryField.DURATION_TO_SLEEP, - GetSleepSummaryField.DURATION_TO_WAKEUP, - GetSleepSummaryField.HR_AVERAGE, - GetSleepSummaryField.HR_MAX, - GetSleepSummaryField.HR_MIN, - GetSleepSummaryField.LIGHT_SLEEP_DURATION, - GetSleepSummaryField.REM_SLEEP_DURATION, - GetSleepSummaryField.RR_AVERAGE, - GetSleepSummaryField.RR_MAX, - GetSleepSummaryField.RR_MIN, - GetSleepSummaryField.SLEEP_SCORE, - GetSleepSummaryField.SNORING, - GetSleepSummaryField.SNORING_EPISODE_COUNT, - GetSleepSummaryField.WAKEUP_COUNT, - GetSleepSummaryField.WAKEUP_DURATION, + response = await self._client.get_sleep_summary_since( + sleep_summary_since=yesterday_noon_utc, + sleep_summary_data_fields=[ + SleepSummaryDataFields.BREATHING_DISTURBANCES_INTENSITY, + SleepSummaryDataFields.DEEP_SLEEP_DURATION, + SleepSummaryDataFields.SLEEP_LATENCY, + SleepSummaryDataFields.WAKE_UP_LATENCY, + SleepSummaryDataFields.AVERAGE_HEART_RATE, + SleepSummaryDataFields.MIN_HEART_RATE, + SleepSummaryDataFields.MAX_HEART_RATE, + SleepSummaryDataFields.LIGHT_SLEEP_DURATION, + SleepSummaryDataFields.REM_SLEEP_DURATION, + SleepSummaryDataFields.AVERAGE_RESPIRATION_RATE, + SleepSummaryDataFields.MIN_RESPIRATION_RATE, + SleepSummaryDataFields.MAX_RESPIRATION_RATE, + SleepSummaryDataFields.SLEEP_SCORE, + SleepSummaryDataFields.SNORING, + SleepSummaryDataFields.SNORING_COUNT, + SleepSummaryDataFields.WAKE_UP_COUNT, + SleepSummaryDataFields.TOTAL_TIME_AWAKE, ], ) - - # Set the default to empty lists. - raw_values: dict[GetSleepSummaryField, list[int]] = { - field: [] for field in GetSleepSummaryField - } - - # Collect the raw data. - for serie in response.series: - data = serie.data - - for field in GetSleepSummaryField: - raw_values[field].append(dict(data)[field.value]) - - values: dict[GetSleepSummaryField, float] = {} - - def average(data: list[int]) -> float: - return sum(data) / len(data) - - def set_value(field: GetSleepSummaryField, func: Callable) -> None: - non_nones = [ - value for value in raw_values.get(field, []) if value is not None - ] - values[field] = func(non_nones) if non_nones else None - - set_value(GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, average) - set_value(GetSleepSummaryField.DEEP_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.DURATION_TO_SLEEP, average) - set_value(GetSleepSummaryField.DURATION_TO_WAKEUP, average) - set_value(GetSleepSummaryField.HR_AVERAGE, average) - set_value(GetSleepSummaryField.HR_MAX, average) - set_value(GetSleepSummaryField.HR_MIN, average) - set_value(GetSleepSummaryField.LIGHT_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.REM_SLEEP_DURATION, sum) - set_value(GetSleepSummaryField.RR_AVERAGE, average) - set_value(GetSleepSummaryField.RR_MAX, average) - set_value(GetSleepSummaryField.RR_MIN, average) - set_value(GetSleepSummaryField.SLEEP_SCORE, max) - set_value(GetSleepSummaryField.SNORING, average) - set_value(GetSleepSummaryField.SNORING_EPISODE_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_COUNT, sum) - set_value(GetSleepSummaryField.WAKEUP_DURATION, average) - - return { - WITHINGS_MEASURE_TYPE_MAP[field]: round(value, 4) - if value is not None - else None - for field, value in values.items() - } + return aggregate_sleep_summary(response) class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): @@ -250,19 +141,19 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non in_bed: bool | None = None _default_update_interval = None - def __init__(self, hass: HomeAssistant, client: ConfigEntryWithingsApi) -> None: + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" super().__init__(hass, client) self.notification_categories = { - NotifyAppli.BED_IN, - NotifyAppli.BED_OUT, + NotificationCategory.IN_BED, + NotificationCategory.OUT_BED, } async def async_webhook_data_updated( - self, notification_category: NotifyAppli + self, notification_category: NotificationCategory ) -> None: """Only set new in bed value instead of refresh.""" - self.in_bed = notification_category == NotifyAppli.BED_IN + self.in_bed = notification_category == NotificationCategory.IN_BED self.async_update_listeners() async def _internal_update_data(self) -> None: diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index edc8aab83b7..9ed7dea08ad 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_polling", "loggers": ["withings_api"], - "requirements": ["withings-api==2.4.0"] + "requirements": ["aiowithings==0.4.4"] } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 200ad7aedd5..535dafc763e 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -1,9 +1,10 @@ """Sensors flow for Withings.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass -from withings_api.common import GetSleepSummaryField, MeasureType +from aiowithings import MeasurementType, SleepSummary from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,6 +23,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( DOMAIN, @@ -32,7 +34,6 @@ from .const import ( UOM_BREATHS_PER_MINUTE, UOM_FREQUENCY, UOM_MMHG, - Measurement, ) from .coordinator import ( WithingsDataUpdateCoordinator, @@ -43,146 +44,130 @@ from .entity import WithingsEntity @dataclass -class WithingsEntityDescriptionMixin: +class WithingsMeasurementSensorEntityDescriptionMixin: """Mixin for describing withings data.""" - measurement: Measurement - measure_type: GetSleepSummaryField | MeasureType + measurement_type: MeasurementType @dataclass -class WithingsSensorEntityDescription( - SensorEntityDescription, WithingsEntityDescriptionMixin +class WithingsMeasurementSensorEntityDescription( + SensorEntityDescription, WithingsMeasurementSensorEntityDescriptionMixin ): """Immutable class for describing withings data.""" MEASUREMENT_SENSORS = [ - WithingsSensorEntityDescription( - key=Measurement.WEIGHT_KG.value, - measurement=Measurement.WEIGHT_KG, - measure_type=MeasureType.WEIGHT, + WithingsMeasurementSensorEntityDescription( + key="weight_kg", + measurement_type=MeasurementType.WEIGHT, native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_MASS_KG.value, - measurement=Measurement.FAT_MASS_KG, - measure_type=MeasureType.FAT_MASS_WEIGHT, + WithingsMeasurementSensorEntityDescription( + key="fat_mass_kg", + measurement_type=MeasurementType.FAT_MASS_WEIGHT, translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_FREE_MASS_KG.value, - measurement=Measurement.FAT_FREE_MASS_KG, - measure_type=MeasureType.FAT_FREE_MASS, + WithingsMeasurementSensorEntityDescription( + key="fat_free_mass_kg", + measurement_type=MeasurementType.FAT_FREE_MASS, translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.MUSCLE_MASS_KG.value, - measurement=Measurement.MUSCLE_MASS_KG, - measure_type=MeasureType.MUSCLE_MASS, + WithingsMeasurementSensorEntityDescription( + key="muscle_mass_kg", + measurement_type=MeasurementType.MUSCLE_MASS, translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.BONE_MASS_KG.value, - measurement=Measurement.BONE_MASS_KG, - measure_type=MeasureType.BONE_MASS, + WithingsMeasurementSensorEntityDescription( + key="bone_mass_kg", + measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HEIGHT_M.value, - measurement=Measurement.HEIGHT_M, - measure_type=MeasureType.HEIGHT, + WithingsMeasurementSensorEntityDescription( + key="height_m", + measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.TEMP_C.value, - measurement=Measurement.TEMP_C, - measure_type=MeasureType.TEMPERATURE, + WithingsMeasurementSensorEntityDescription( + key="temperature_c", + measurement_type=MeasurementType.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.BODY_TEMP_C.value, - measurement=Measurement.BODY_TEMP_C, - measure_type=MeasureType.BODY_TEMPERATURE, + WithingsMeasurementSensorEntityDescription( + key="body_temperature_c", + measurement_type=MeasurementType.BODY_TEMPERATURE, translation_key="body_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SKIN_TEMP_C.value, - measurement=Measurement.SKIN_TEMP_C, - measure_type=MeasureType.SKIN_TEMPERATURE, + WithingsMeasurementSensorEntityDescription( + key="skin_temperature_c", + measurement_type=MeasurementType.SKIN_TEMPERATURE, translation_key="skin_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.FAT_RATIO_PCT.value, - measurement=Measurement.FAT_RATIO_PCT, - measure_type=MeasureType.FAT_RATIO, + WithingsMeasurementSensorEntityDescription( + key="fat_ratio_pct", + measurement_type=MeasurementType.FAT_RATIO, translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.DIASTOLIC_MMHG.value, - measurement=Measurement.DIASTOLIC_MMHG, - measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, + WithingsMeasurementSensorEntityDescription( + key="diastolic_blood_pressure_mmhg", + measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SYSTOLIC_MMGH.value, - measurement=Measurement.SYSTOLIC_MMGH, - measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, + WithingsMeasurementSensorEntityDescription( + key="systolic_blood_pressure_mmhg", + measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE, translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HEART_PULSE_BPM.value, - measurement=Measurement.HEART_PULSE_BPM, - measure_type=MeasureType.HEART_RATE, + WithingsMeasurementSensorEntityDescription( + key="heart_pulse_bpm", + measurement_type=MeasurementType.HEART_RATE, translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.SPO2_PCT.value, - measurement=Measurement.SPO2_PCT, - measure_type=MeasureType.SP02, + WithingsMeasurementSensorEntityDescription( + key="spo2_pct", + measurement_type=MeasurementType.SP02, translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsSensorEntityDescription( - key=Measurement.HYDRATION.value, - measurement=Measurement.HYDRATION, - measure_type=MeasureType.HYDRATION, + WithingsMeasurementSensorEntityDescription( + key="hydration", + measurement_type=MeasurementType.HYDRATION, translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, @@ -190,29 +175,42 @@ MEASUREMENT_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.PWV.value, - measurement=Measurement.PWV, - measure_type=MeasureType.PULSE_WAVE_VELOCITY, + WithingsMeasurementSensorEntityDescription( + key="pulse_wave_velocity", + measurement_type=MeasurementType.PULSE_WAVE_VELOCITY, translation_key="pulse_wave_velocity", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, ), ] + + +@dataclass +class WithingsSleepSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[SleepSummary], StateType] + + +@dataclass +class WithingsSleepSensorEntityDescription( + SensorEntityDescription, WithingsSleepSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + SLEEP_SENSORS = [ - WithingsSensorEntityDescription( - key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, - measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, - measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, + WithingsSleepSensorEntityDescription( + key="sleep_breathing_disturbances_intensity", + value_fn=lambda sleep_summary: sleep_summary.breathing_disturbances_intensity, translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_deep_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.deep_sleep_duration, translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -220,10 +218,9 @@ SLEEP_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DURATION_TO_SLEEP, + WithingsSleepSensorEntityDescription( + key="sleep_tosleep_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.sleep_latency, translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -231,10 +228,9 @@ SLEEP_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP, + WithingsSleepSensorEntityDescription( + key="sleep_towakeup_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.wake_up_latency, translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", @@ -242,40 +238,36 @@ SLEEP_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, - measurement=Measurement.SLEEP_HEART_RATE_AVERAGE, - measure_type=GetSleepSummaryField.HR_AVERAGE, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_average_bpm", + value_fn=lambda sleep_summary: sleep_summary.average_heart_rate, translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_MAX.value, - measurement=Measurement.SLEEP_HEART_RATE_MAX, - measure_type=GetSleepSummaryField.HR_MAX, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_max_bpm", + value_fn=lambda sleep_summary: sleep_summary.max_heart_rate, translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_HEART_RATE_MIN.value, - measurement=Measurement.SLEEP_HEART_RATE_MIN, - measure_type=GetSleepSummaryField.HR_MIN, + WithingsSleepSensorEntityDescription( + key="sleep_heart_rate_min_bpm", + value_fn=lambda sleep_summary: sleep_summary.min_heart_rate, translation_key="minimum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS, - measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_light_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.light_sleep_duration, translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -283,10 +275,9 @@ SLEEP_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_REM_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_REM_DURATION_SECONDS, - measure_type=GetSleepSummaryField.REM_SLEEP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_rem_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.rem_sleep_duration, translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", @@ -294,73 +285,65 @@ SLEEP_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, - measure_type=GetSleepSummaryField.RR_AVERAGE, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_average_bpm", + value_fn=lambda sleep_summary: sleep_summary.average_respiration_rate, translation_key="average_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX, - measure_type=GetSleepSummaryField.RR_MAX, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_max_bpm", + value_fn=lambda sleep_summary: sleep_summary.max_respiration_rate, translation_key="maximum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, - measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN, - measure_type=GetSleepSummaryField.RR_MIN, + WithingsSleepSensorEntityDescription( + key="sleep_respiratory_min_bpm", + value_fn=lambda sleep_summary: sleep_summary.min_respiration_rate, translation_key="minimum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SCORE.value, - measurement=Measurement.SLEEP_SCORE, - measure_type=GetSleepSummaryField.SLEEP_SCORE, + WithingsSleepSensorEntityDescription( + key="sleep_score", + value_fn=lambda sleep_summary: sleep_summary.sleep_score, translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SNORING.value, - measurement=Measurement.SLEEP_SNORING, - measure_type=GetSleepSummaryField.SNORING, + WithingsSleepSensorEntityDescription( + key="sleep_snoring", + value_fn=lambda sleep_summary: sleep_summary.snoring, translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, - measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT, - measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT, + WithingsSleepSensorEntityDescription( + key="sleep_snoring_eposode_count", + value_fn=lambda sleep_summary: sleep_summary.snoring_count, translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_WAKEUP_COUNT.value, - measurement=Measurement.SLEEP_WAKEUP_COUNT, - measure_type=GetSleepSummaryField.WAKEUP_COUNT, + WithingsSleepSensorEntityDescription( + key="sleep_wakeup_count", + value_fn=lambda sleep_summary: sleep_summary.wake_up_count, translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsSensorEntityDescription( - key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, - measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS, - measure_type=GetSleepSummaryField.WAKEUP_DURATION, + WithingsSleepSensorEntityDescription( + key="sleep_wakeup_duration_seconds", + value_fn=lambda sleep_summary: sleep_summary.total_time_awake, translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", @@ -398,38 +381,51 @@ async def async_setup_entry( class WithingsSensor(WithingsEntity, SensorEntity): """Implementation of a Withings sensor.""" - entity_description: WithingsSensorEntityDescription - def __init__( self, coordinator: WithingsDataUpdateCoordinator, - entity_description: WithingsSensorEntityDescription, + entity_description: SensorEntityDescription, ) -> None: """Initialize sensor.""" super().__init__(coordinator, entity_description.key) self.entity_description = entity_description - @property - def native_value(self) -> None | str | int | float: - """Return the state of the entity.""" - return self.coordinator.data[self.entity_description.measurement] - - @property - def available(self) -> bool: - """Return if the sensor is available.""" - return ( - super().available - and self.entity_description.measurement in self.coordinator.data - ) - class WithingsMeasurementSensor(WithingsSensor): """Implementation of a Withings measurement sensor.""" coordinator: WithingsMeasurementDataUpdateCoordinator + entity_description: WithingsMeasurementSensorEntityDescription + + @property + def native_value(self) -> float: + """Return the state of the entity.""" + return self.coordinator.data[self.entity_description.measurement_type] + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return ( + super().available + and self.entity_description.measurement_type in self.coordinator.data + ) + class WithingsSleepSensor(WithingsSensor): """Implementation of a Withings sleep sensor.""" coordinator: WithingsSleepDataUpdateCoordinator + + entity_description: WithingsSleepSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + assert self.coordinator.data + return self.entity_description.value_fn(self.coordinator.data) + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return super().available and self.coordinator.data is not None diff --git a/requirements_all.txt b/requirements_all.txt index b1759ec6479..eb40661478f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -386,6 +386,9 @@ aiowatttime==0.1.1 # homeassistant.components.webostv aiowebostv==0.3.3 +# homeassistant.components.withings +aiowithings==0.4.4 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -2717,9 +2720,6 @@ wiffi==1.1.2 # homeassistant.components.wirelesstag wirelesstagpy==0.8.1 -# homeassistant.components.withings -withings-api==2.4.0 - # homeassistant.components.wled wled==0.16.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c745e407561..7628ab06bbd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -361,6 +361,9 @@ aiowatttime==0.1.1 # homeassistant.components.webostv aiowebostv==0.3.3 +# homeassistant.components.withings +aiowithings==0.4.4 + # homeassistant.components.yandex_transport aioymaps==1.2.2 @@ -2020,9 +2023,6 @@ whois==0.9.27 # homeassistant.components.wiffi wiffi==1.1.2 -# homeassistant.components.withings -withings-api==2.4.0 - # homeassistant.components.wled wled==0.16.0 diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index ad310639b43..5c4a1db1182 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -3,19 +3,14 @@ from datetime import timedelta import time from unittest.mock import AsyncMock, patch +from aiowithings import Device, MeasurementGroup, SleepSummary, WithingsClient +from aiowithings.models import NotificationConfiguration import pytest -from withings_api import ( - MeasureGetMeasResponse, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.withings.api import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -133,22 +128,34 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: def mock_withings(): """Mock withings.""" - mock = AsyncMock(spec=ConfigEntryWithingsApi) - mock.user_get_device.return_value = UserGetDeviceResponse( - **load_json_object_fixture("withings/get_device.json") - ) - mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( - **load_json_object_fixture("withings/get_meas.json") - ) - mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( - **load_json_object_fixture("withings/get_sleep.json") - ) - mock.async_notify_list.return_value = NotifyListResponse( - **load_json_object_fixture("withings/notify_list.json") - ) + devices_json = load_json_object_fixture("withings/get_device.json") + devices = [Device.from_api(device) for device in devices_json["devices"]] + + meas_json = load_json_object_fixture("withings/get_meas.json") + measurement_groups = [ + MeasurementGroup.from_api(measurement) + for measurement in meas_json["measuregrps"] + ] + + sleep_json = load_json_object_fixture("withings/get_sleep.json") + sleep_summaries = [ + SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json["series"] + ] + + notification_json = load_json_object_fixture("withings/notify_list.json") + notifications = [ + NotificationConfiguration.from_api(not_conf) + for not_conf in notification_json["profiles"] + ] + + mock = AsyncMock(spec=WithingsClient) + mock.get_devices.return_value = devices + mock.get_measurement_in_period.return_value = measurement_groups + mock.get_sleep_summary_since.return_value = sleep_summaries + mock.list_notification_configurations.return_value = notifications with patch( - "homeassistant.components.withings.ConfigEntryWithingsApi", + "homeassistant.components.withings.WithingsClient", return_value=mock, ): yield mock diff --git a/tests/components/withings/fixtures/empty_notify_list.json b/tests/components/withings/fixtures/empty_notify_list.json deleted file mode 100644 index c905c95e4cb..00000000000 --- a/tests/components/withings/fixtures/empty_notify_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "profiles": [] -} diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 833ac4148a0..886cf86f034 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -11,7 +11,7 @@ 'entity_id': 'sensor.henk_weight', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '70', }) # --- # name: test_all_entities.1 @@ -26,7 +26,7 @@ 'entity_id': 'sensor.henk_fat_mass', 'last_changed': , 'last_updated': , - 'state': '5.0', + 'state': '5', }) # --- # name: test_all_entities.10 @@ -40,7 +40,7 @@ 'entity_id': 'sensor.henk_diastolic_blood_pressure', 'last_changed': , 'last_updated': , - 'state': '70.0', + 'state': '70', }) # --- # name: test_all_entities.11 @@ -54,7 +54,7 @@ 'entity_id': 'sensor.henk_systolic_blood_pressure', 'last_changed': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_all_entities.12 @@ -69,7 +69,7 @@ 'entity_id': 'sensor.henk_heart_pulse', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '60', }) # --- # name: test_all_entities.13 @@ -114,7 +114,7 @@ 'entity_id': 'sensor.henk_pulse_wave_velocity', 'last_changed': , 'last_updated': , - 'state': '100.0', + 'state': '100', }) # --- # name: test_all_entities.16 @@ -127,7 +127,7 @@ 'entity_id': 'sensor.henk_breathing_disturbances_intensity', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.17 @@ -159,7 +159,7 @@ 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_updated': , - 'state': '780.0', + 'state': '780', }) # --- # name: test_all_entities.19 @@ -175,7 +175,7 @@ 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_updated': , - 'state': '996.0', + 'state': '996', }) # --- # name: test_all_entities.2 @@ -190,7 +190,7 @@ 'entity_id': 'sensor.henk_fat_free_mass', 'last_changed': , 'last_updated': , - 'state': '60.0', + 'state': '60', }) # --- # name: test_all_entities.20 @@ -205,7 +205,7 @@ 'entity_id': 'sensor.henk_average_heart_rate', 'last_changed': , 'last_updated': , - 'state': '83.2', + 'state': '83', }) # --- # name: test_all_entities.21 @@ -220,7 +220,7 @@ 'entity_id': 'sensor.henk_maximum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '108.4', + 'state': '108', }) # --- # name: test_all_entities.22 @@ -235,7 +235,7 @@ 'entity_id': 'sensor.henk_minimum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '58.0', + 'state': '58', }) # --- # name: test_all_entities.23 @@ -281,7 +281,7 @@ 'entity_id': 'sensor.henk_average_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '14.2', + 'state': '14', }) # --- # name: test_all_entities.26 @@ -295,7 +295,7 @@ 'entity_id': 'sensor.henk_maximum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '20.0', + 'state': '20', }) # --- # name: test_all_entities.27 @@ -309,7 +309,7 @@ 'entity_id': 'sensor.henk_minimum_respiratory_rate', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.28 @@ -337,7 +337,7 @@ 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_updated': , - 'state': '1044.0', + 'state': '1044', }) # --- # name: test_all_entities.3 @@ -352,7 +352,7 @@ 'entity_id': 'sensor.henk_muscle_mass', 'last_changed': , 'last_updated': , - 'state': '50.0', + 'state': '50', }) # --- # name: test_all_entities.30 @@ -396,7 +396,7 @@ 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_updated': , - 'state': '3468.0', + 'state': '3468', }) # --- # name: test_all_entities.33 @@ -568,7 +568,7 @@ 'entity_id': 'sensor.henk_bone_mass', 'last_changed': , 'last_updated': , - 'state': '10.0', + 'state': '10', }) # --- # name: test_all_entities.40 @@ -820,7 +820,7 @@ 'entity_id': 'sensor.henk_height', 'last_changed': , 'last_updated': , - 'state': '2.0', + 'state': '2', }) # --- # name: test_all_entities.50 @@ -1065,7 +1065,7 @@ 'entity_id': 'sensor.henk_temperature', 'last_changed': , 'last_updated': , - 'state': '40.0', + 'state': '40', }) # --- # name: test_all_entities.60 @@ -1220,7 +1220,7 @@ 'entity_id': 'sensor.henk_body_temperature', 'last_changed': , 'last_updated': , - 'state': '40.0', + 'state': '40', }) # --- # name: test_all_entities.8 @@ -1235,7 +1235,7 @@ 'entity_id': 'sensor.henk_skin_temperature', 'last_changed': , 'last_updated': , - 'state': '20.0', + 'state': '20', }) # --- # name: test_all_entities.9 diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index aa757486f86..5054bf46daa 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -2,9 +2,9 @@ from unittest.mock import AsyncMock from aiohttp.client_exceptions import ClientResponseError +from aiowithings import NotificationCategory from freezegun.api import FrozenDateTimeFactory import pytest -from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant @@ -36,7 +36,7 @@ async def test_binary_sensor( resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + {"userid": USER_ID, "appli": NotificationCategory.IN_BED}, client, ) assert resp.message_code == 0 @@ -46,7 +46,7 @@ async def test_binary_sensor( resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + {"userid": USER_ID, "appli": NotificationCategory.OUT_BED}, client, ) assert resp.message_code == 0 @@ -73,6 +73,6 @@ async def test_polling_binary_sensor( await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + {"userid": USER_ID, "appli": NotificationCategory.IN_BED}, client, ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index a3509c8547b..7576802999e 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -4,11 +4,14 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse +from aiowithings import ( + NotificationCategory, + WithingsAuthenticationFailedError, + WithingsUnauthorizedError, +) from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol -from withings_api import NotifyListResponse -from withings_api.common import AuthFailedException, NotifyAppli, UnauthorizedException from homeassistant import config_entries from homeassistant.components.cloud import CloudNotAvailable @@ -26,7 +29,6 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, async_mock_cloud_connection_status, - load_json_object_fixture, ) from tests.components.cloud import mock_cloud from tests.typing import ClientSessionGenerator @@ -126,19 +128,29 @@ async def test_data_manager_webhook_subscription( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() - assert withings.async_notify_subscribe.call_count == 6 + assert withings.subscribe_notification.call_count == 6 webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) - withings.async_notify_subscribe.assert_any_call( - webhook_url, NotifyAppli.CIRCULATORY + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.WEIGHT + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.PRESSURE + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.ACTIVITY + ) + withings.subscribe_notification.assert_any_call( + webhook_url, NotificationCategory.SLEEP ) - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) - withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) - withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) - withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + withings.revoke_notification_configurations.assert_any_call( + webhook_url, NotificationCategory.IN_BED + ) + withings.revoke_notification_configurations.assert_any_call( + webhook_url, NotificationCategory.OUT_BED + ) async def test_webhook_subscription_polling_config( @@ -149,16 +161,16 @@ async def test_webhook_subscription_polling_config( freezer: FrozenDateTimeFactory, ) -> None: """Test webhook subscriptions not run when polling.""" - await setup_integration(hass, polling_config_entry) + await setup_integration(hass, polling_config_entry, False) await hass_client_no_auth() await hass.async_block_till_done() freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() - assert withings.notify_revoke.call_count == 0 - assert withings.notify_subscribe.call_count == 0 - assert withings.notify_list.call_count == 0 + assert withings.revoke_notification_configurations.call_count == 0 + assert withings.subscribe_notification.call_count == 0 + assert withings.list_notification_configurations.call_count == 0 @pytest.mark.parametrize( @@ -200,22 +212,22 @@ async def test_webhooks_request_data( client = await hass_client_no_auth() - assert withings.async_measure_get_meas.call_count == 1 + assert withings.get_measurement_in_period.call_count == 1 await call_webhook( hass, WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, + {"userid": USER_ID, "appli": NotificationCategory.WEIGHT}, client, ) - assert withings.async_measure_get_meas.call_count == 2 + assert withings.get_measurement_in_period.call_count == 2 @pytest.mark.parametrize( "error", [ - UnauthorizedException(401), - AuthFailedException(500), + WithingsUnauthorizedError(401), + WithingsAuthenticationFailedError(500), ], ) async def test_triggering_reauth( @@ -228,7 +240,7 @@ async def test_triggering_reauth( """Test triggering reauth.""" await setup_integration(hass, polling_config_entry, False) - withings.async_measure_get_meas.side_effect = error + withings.get_measurement_in_period.side_effect = error freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -384,7 +396,7 @@ async def test_setup_with_cloud( "homeassistant.components.cloud.async_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ) as fake_create_cloudhook, patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( "homeassistant.components.cloud.async_delete_cloudhook" ) as fake_delete_cloudhook, patch( @@ -462,7 +474,7 @@ async def test_cloud_disconnect( "homeassistant.components.cloud.async_create_cloudhook", return_value="https://hooks.nabu.casa/ABCD", ), patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + "homeassistant.components.withings.async_get_config_entry_implementation", ), patch( "homeassistant.components.cloud.async_delete_cloudhook" ), patch( @@ -475,34 +487,31 @@ async def test_cloud_disconnect( await hass.async_block_till_done() - withings.async_notify_list.return_value = NotifyListResponse( - **load_json_object_fixture("withings/empty_notify_list.json") - ) + withings.list_notification_configurations.return_value = [] - assert withings.async_notify_subscribe.call_count == 6 + assert withings.subscribe_notification.call_count == 6 async_mock_cloud_connection_status(hass, False) await hass.async_block_till_done() - assert withings.async_notify_revoke.call_count == 3 + assert withings.revoke_notification_configurations.call_count == 3 async_mock_cloud_connection_status(hass, True) await hass.async_block_till_done() - assert withings.async_notify_subscribe.call_count == 12 + assert withings.subscribe_notification.call_count == 12 @pytest.mark.parametrize( ("body", "expected_code"), [ - [{"userid": 0, "appli": NotifyAppli.WEIGHT.value}, 0], # Success + [{"userid": 0, "appli": NotificationCategory.WEIGHT.value}, 0], # Success [{"userid": None, "appli": 1}, 0], # Success, we ignore the user_id. [{}, 12], # No request body. [{"userid": "GG"}, 20], # appli not provided. [{"userid": 0}, 20], # appli not provided. - [{"userid": 0, "appli": 99}, 21], # Invalid appli. [ - {"userid": 11, "appli": NotifyAppli.WEIGHT.value}, + {"userid": 11, "appli": NotificationCategory.WEIGHT.value}, 0, ], # Success, we ignore the user_id ], diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index f5d15e5dea9..dd3dee1bb4d 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -57,7 +57,7 @@ async def test_update_failed( """Test all entities.""" await setup_integration(hass, polling_config_entry, False) - withings.async_measure_get_meas.side_effect = Exception + withings.get_measurement_in_period.side_effect = Exception freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() From 302b44426985aa4d0547fcef021632ce0f0b77ca Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Sat, 14 Oct 2023 14:44:16 +0200 Subject: [PATCH 438/968] Add service set_preset_mode_with_end_datetime in Netatmo integration (#101674) * Add Netatmo climate service set_preset_mode_with_end_datetime to allow you to specify an end datetime for a preset mode * Make end date optional * address review comments * Update strings * Update homeassistant/components/netatmo/const.py * Update homeassistant/components/netatmo/strings.json --------- Co-authored-by: Adrien JOLY Co-authored-by: G Johansson --- homeassistant/components/netatmo/climate.py | 33 +++++++- homeassistant/components/netatmo/const.py | 2 + .../components/netatmo/services.yaml | 20 +++++ homeassistant/components/netatmo/strings.json | 14 ++++ tests/components/netatmo/test_climate.py | 77 +++++++++++++++++++ 5 files changed, 145 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 9f34df9b39c..f4715015844 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -8,6 +8,7 @@ from pyatmo.modules import NATherm1 import voluptuous as vol from homeassistant.components.climate import ( + ATTR_PRESET_MODE, DEFAULT_MIN_TEMP, PRESET_AWAY, PRESET_BOOST, @@ -30,8 +31,10 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import dt as dt_util from .const import ( + ATTR_END_DATETIME, ATTR_HEATING_POWER_REQUEST, ATTR_SCHEDULE_NAME, ATTR_SELECTED_SCHEDULE, @@ -43,6 +46,7 @@ from .const import ( EVENT_TYPE_SET_POINT, EVENT_TYPE_THERM_MODE, NETATMO_CREATE_CLIMATE, + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, ) from .data_handler import HOME, SIGNAL_NAME, NetatmoRoom @@ -59,6 +63,8 @@ SUPPORT_FLAGS = ( ) SUPPORT_PRESET = [PRESET_AWAY, PRESET_BOOST, PRESET_FROST_GUARD, PRESET_SCHEDULE] +THERM_MODES = (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY) + STATE_NETATMO_SCHEDULE = "schedule" STATE_NETATMO_HG = "hg" STATE_NETATMO_MAX = "max" @@ -124,6 +130,14 @@ async def async_setup_entry( {vol.Required(ATTR_SCHEDULE_NAME): cv.string}, "_async_service_set_schedule", ) + platform.async_register_entity_service( + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + vol.Required(ATTR_PRESET_MODE): vol.In(THERM_MODES), + vol.Required(ATTR_END_DATETIME): cv.datetime, + }, + "_async_service_set_preset_mode_with_end_datetime", + ) class NetatmoThermostat(NetatmoBase, ClimateEntity): @@ -314,7 +328,7 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): await self._room.async_therm_set(STATE_NETATMO_HOME) elif preset_mode in (PRESET_BOOST, STATE_NETATMO_MAX): await self._room.async_therm_set(PRESET_MAP_NETATMO[preset_mode]) - elif preset_mode in (PRESET_SCHEDULE, PRESET_FROST_GUARD, PRESET_AWAY): + elif preset_mode in THERM_MODES: await self._room.home.async_set_thermmode(PRESET_MAP_NETATMO[preset_mode]) else: _LOGGER.error("Preset mode '%s' not available", preset_mode) @@ -410,6 +424,23 @@ class NetatmoThermostat(NetatmoBase, ClimateEntity): schedule_id, ) + async def _async_service_set_preset_mode_with_end_datetime( + self, **kwargs: Any + ) -> None: + preset_mode = kwargs[ATTR_PRESET_MODE] + end_datetime = kwargs[ATTR_END_DATETIME] + end_timestamp = int(dt_util.as_timestamp(end_datetime)) + + await self._room.home.async_set_thermmode( + mode=PRESET_MAP_NETATMO[preset_mode], end_time=end_timestamp + ) + _LOGGER.debug( + "Setting %s preset to %s with optional end datetime to %s", + self._room.home.entity_id, + preset_mode, + end_timestamp, + ) + @property def device_info(self) -> DeviceInfo: """Return the device info for the thermostat.""" diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 3e489fe8ea5..9e7ac33c8b6 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -69,6 +69,7 @@ DEFAULT_PERSON = "unknown" DEFAULT_WEBHOOKS = False ATTR_CAMERA_LIGHT_MODE = "camera_light_mode" +ATTR_END_DATETIME = "end_datetime" ATTR_EVENT_TYPE = "event_type" ATTR_FACE_URL = "face_url" ATTR_HEATING_POWER_REQUEST = "heating_power_request" @@ -86,6 +87,7 @@ SERVICE_SET_CAMERA_LIGHT = "set_camera_light" SERVICE_SET_PERSON_AWAY = "set_person_away" SERVICE_SET_PERSONS_HOME = "set_persons_home" SERVICE_SET_SCHEDULE = "set_schedule" +SERVICE_SET_PRESET_MODE_WITH_END_DATETIME = "set_preset_mode_with_end_datetime" # Climate events EVENT_TYPE_CANCEL_SET_POINT = "cancel_set_point" diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml index 726d6867d2d..228f84f175d 100644 --- a/homeassistant/components/netatmo/services.yaml +++ b/homeassistant/components/netatmo/services.yaml @@ -26,6 +26,26 @@ set_schedule: selector: text: +set_preset_mode_with_end_datetime: + target: + entity: + integration: netatmo + domain: climate + fields: + preset_mode: + required: true + example: "away" + selector: + select: + options: + - "away" + - "Frost Guard" + end_datetime: + required: true + example: '"2019-04-20 05:04:20"' + selector: + datetime: + set_persons_home: target: entity: diff --git a/homeassistant/components/netatmo/strings.json b/homeassistant/components/netatmo/strings.json index e9125f33016..593320827fd 100644 --- a/homeassistant/components/netatmo/strings.json +++ b/homeassistant/components/netatmo/strings.json @@ -115,6 +115,20 @@ "unregister_webhook": { "name": "Unregister webhook", "description": "Unregisters the webhook from the Netatmo backend." + }, + "set_preset_mode_with_end_datetime": { + "name": "Set preset mode with end datetime", + "description": "Sets the preset mode for a Netatmo climate device. The preset mode must match a preset mode configured at Netatmo.", + "fields": { + "preset_mode": { + "name": "Preset mode", + "description": "Climate preset mode such as Schedule, Away or Frost Guard." + }, + "end_datetime": { + "name": "End datetime", + "description": "Datetime for until when the preset will be active." + } + } } } } diff --git a/tests/components/netatmo/test_climate.py b/tests/components/netatmo/test_climate.py index 6e4ae0e67cb..99000403a38 100644 --- a/tests/components/netatmo/test_climate.py +++ b/tests/components/netatmo/test_climate.py @@ -1,7 +1,9 @@ """The tests for the Netatmo climate platform.""" +from datetime import timedelta from unittest.mock import patch import pytest +from voluptuous.error import MultipleInvalid from homeassistant.components.climate import ( ATTR_HVAC_MODE, @@ -18,11 +20,14 @@ from homeassistant.components.climate import ( ) from homeassistant.components.netatmo.climate import PRESET_FROST_GUARD, PRESET_SCHEDULE from homeassistant.components.netatmo.const import ( + ATTR_END_DATETIME, ATTR_SCHEDULE_NAME, + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, SERVICE_SET_SCHEDULE, ) from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from .common import selected_platforms, simulate_webhook @@ -458,6 +463,78 @@ async def test_service_schedule_thermostats( assert "summer is not a valid schedule" in caplog.text +async def test_service_preset_mode_with_end_time_thermostats( + hass: HomeAssistant, config_entry, caplog: pytest.LogCaptureFixture, netatmo_auth +) -> None: + """Test service for set preset mode with end datetime for Netatmo thermostats.""" + with selected_platforms(["climate"]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + climate_entity_livingroom = "climate.livingroom" + + # Test setting a valid preset mode (that allow an end datetime in Netatmo == THERM_MODES) and a valid end datetime + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_AWAY, + ATTR_END_DATETIME: (dt_util.now() + timedelta(days=10)).strftime( + "%Y-%m-%d %H:%M:%S" + ), + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Fake webhook thermostat mode change to "Away" + response = { + "event_type": "therm_mode", + "home": {"id": "91763b24c43d3e344f424e8b", "therm_mode": "away"}, + "mode": "away", + "previous_mode": "schedule", + "push_type": "home_event_changed", + } + await simulate_webhook(hass, webhook_id, response) + + assert hass.states.get(climate_entity_livingroom).state == "auto" + assert ( + hass.states.get(climate_entity_livingroom).attributes["preset_mode"] == "away" + ) + + # Test setting an invalid preset mode (not in THERM_MODES) and a valid end datetime + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_BOOST, + ATTR_END_DATETIME: (dt_util.now() + timedelta(days=10)).strftime( + "%Y-%m-%d %H:%M:%S" + ), + }, + blocking=True, + ) + await hass.async_block_till_done() + + # Test setting a valid preset mode (that allow an end datetime in Netatmo == THERM_MODES) without an end datetime + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + "netatmo", + SERVICE_SET_PRESET_MODE_WITH_END_DATETIME, + { + ATTR_ENTITY_ID: climate_entity_livingroom, + ATTR_PRESET_MODE: PRESET_AWAY, + }, + blocking=True, + ) + await hass.async_block_till_done() + + async def test_service_preset_mode_already_boost_valves( hass: HomeAssistant, config_entry, netatmo_auth ) -> None: From 76083a0b4cc0e0efbb78a41e3bee76df64d3aad2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 14 Oct 2023 16:19:04 +0200 Subject: [PATCH 439/968] Update Withings measurements incrementally after the first update (#102002) * Update incrementally after the first update * Update incrementally after the first update --- .../components/withings/coordinator.py | 22 +++-- tests/components/withings/conftest.py | 1 + .../withings/fixtures/get_meas_1.json | 97 +++++++++++++++++++ tests/components/withings/test_init.py | 6 +- tests/components/withings/test_sensor.py | 55 ++++++++++- 5 files changed, 171 insertions(+), 10 deletions(-) create mode 100644 tests/components/withings/fixtures/get_meas_1.json diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index b87cb550a13..c5192ba3466 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,6 +1,6 @@ """Withings coordinator.""" from abc import abstractmethod -from datetime import timedelta +from datetime import datetime, timedelta from typing import TypeVar from aiowithings import ( @@ -33,6 +33,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): config_entry: ConfigEntry _default_update_interval: timedelta | None = UPDATE_INTERVAL + _last_valid_update: datetime | None = None def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" @@ -80,15 +81,24 @@ class WithingsMeasurementDataUpdateCoordinator( NotificationCategory.ACTIVITY, NotificationCategory.PRESSURE, } + self._previous_data: dict[MeasurementType, float] = {} async def _internal_update_data(self) -> dict[MeasurementType, float]: """Retrieve measurement data.""" - now = dt_util.utcnow() - startdate = now - timedelta(days=7) + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + measurements = await self._client.get_measurement_in_period(startdate, now) + else: + measurements = await self._client.get_measurement_since( + self._last_valid_update + ) - response = await self._client.get_measurement_in_period(startdate, now) - - return aggregate_measurements(response) + if measurements: + self._last_valid_update = measurements[0].taken_at + aggregated_measurements = aggregate_measurements(measurements) + self._previous_data.update(aggregated_measurements) + return self._previous_data class WithingsSleepDataUpdateCoordinator( diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 5c4a1db1182..3f3a82a03f3 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -151,6 +151,7 @@ def mock_withings(): mock = AsyncMock(spec=WithingsClient) mock.get_devices.return_value = devices mock.get_measurement_in_period.return_value = measurement_groups + mock.get_measurement_since.return_value = measurement_groups mock.get_sleep_summary_since.return_value = sleep_summaries mock.list_notification_configurations.return_value = notifications diff --git a/tests/components/withings/fixtures/get_meas_1.json b/tests/components/withings/fixtures/get_meas_1.json new file mode 100644 index 00000000000..a1415695746 --- /dev/null +++ b/tests/components/withings/fixtures/get_meas_1.json @@ -0,0 +1,97 @@ +[ + { + "grpid": 1, + "attrib": 0, + "date": 1618605055, + "created": 1618605055, + "modified": 1618605055, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 5 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + } +] diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 7576802999e..72b9b495344 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -212,6 +212,7 @@ async def test_webhooks_request_data( client = await hass_client_no_auth() + assert withings.get_measurement_since.call_count == 0 assert withings.get_measurement_in_period.call_count == 1 await call_webhook( @@ -220,7 +221,8 @@ async def test_webhooks_request_data( {"userid": USER_ID, "appli": NotificationCategory.WEIGHT}, client, ) - assert withings.get_measurement_in_period.call_count == 2 + assert withings.get_measurement_since.call_count == 1 + assert withings.get_measurement_in_period.call_count == 1 @pytest.mark.parametrize( @@ -240,7 +242,7 @@ async def test_triggering_reauth( """Test triggering reauth.""" await setup_integration(hass, polling_config_entry, False) - withings.get_measurement_in_period.side_effect = error + withings.get_measurement_since.side_effect = error freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index dd3dee1bb4d..3a937a5f686 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock +from aiowithings import MeasurementGroup from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -16,7 +17,11 @@ from homeassistant.helpers import entity_registry as er from . import setup_integration from .conftest import USER_ID -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, +) async def async_get_entity_id( @@ -57,7 +62,7 @@ async def test_update_failed( """Test all entities.""" await setup_integration(hass, polling_config_entry, False) - withings.get_measurement_in_period.side_effect = Exception + withings.get_measurement_since.side_effect = Exception freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() @@ -65,3 +70,49 @@ async def test_update_failed( state = hass.states.get("sensor.henk_weight") assert state is not None assert state.state == STATE_UNAVAILABLE + + +async def test_update_updates_incrementally( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching new data updates since the last valid update.""" + await setup_integration(hass, polling_config_entry, False) + + async def _skip_10_minutes() -> None: + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + meas_json = load_json_array_fixture("withings/get_meas_1.json") + measurement_groups = [ + MeasurementGroup.from_api(measurement) for measurement in meas_json + ] + + assert withings.get_measurement_since.call_args_list == [] + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[0].args[0]) + == "2019-08-01 12:00:00+00:00" + ) + + withings.get_measurement_since.return_value = measurement_groups + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[1].args[0]) + == "2019-08-01 12:00:00+00:00" + ) + + await _skip_10_minutes() + assert ( + str(withings.get_measurement_since.call_args_list[2].args[0]) + == "2021-04-16 20:30:55+00:00" + ) + + state = hass.states.get("sensor.henk_weight") + assert state is not None + assert state.state == "71" + assert len(withings.get_measurement_in_period.call_args_list) == 1 From 2d1afc1c7dbd86f054d328a196d7fc08050eeb9f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sat, 14 Oct 2023 10:31:46 -0600 Subject: [PATCH 440/968] Add state translations for OpenUV UV Level sensor (#101978) --- homeassistant/components/openuv/sensor.py | 13 ++++++++----- homeassistant/components/openuv/strings.json | 9 ++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 6c4bff855a4..8434b6d5591 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -54,11 +55,11 @@ class UvLabel: UV_LABEL_DEFINITIONS = ( - UvLabel(value="Extreme", minimum_index=11), - UvLabel(value="Very High", minimum_index=8), - UvLabel(value="High", minimum_index=6), - UvLabel(value="Moderate", minimum_index=3), - UvLabel(value="Low", minimum_index=0), + UvLabel(value="extreme", minimum_index=11), + UvLabel(value="very_high", minimum_index=8), + UvLabel(value="high", minimum_index=6), + UvLabel(value="moderate", minimum_index=3), + UvLabel(value="low", minimum_index=0), ) @@ -104,6 +105,8 @@ SENSOR_DESCRIPTIONS = ( key=TYPE_CURRENT_UV_LEVEL, translation_key="current_uv_level", icon="mdi:weather-sunny", + device_class=SensorDeviceClass.ENUM, + options=[label.value for label in UV_LABEL_DEFINITIONS], value_fn=lambda data: get_uv_label(data["uv"]), ), OpenUvSensorEntityDescription( diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json index 2534622975c..9349d2cc116 100644 --- a/homeassistant/components/openuv/strings.json +++ b/homeassistant/components/openuv/strings.json @@ -51,7 +51,14 @@ "name": "Current UV index" }, "current_uv_level": { - "name": "Current UV level" + "name": "Current UV level", + "state": { + "extreme": "Extreme", + "high": "High", + "low": "Low", + "moderate": "Moderate", + "very_high": "Very high" + } }, "max_uv_index": { "name": "Max UV index" From 7b2aa3a369d18139c357c4606e2e8153a2e949b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Oct 2023 08:40:16 -1000 Subject: [PATCH 441/968] Improve performance of config/entity_registry/get* calls (#101984) --- .../components/config/entity_registry.py | 18 +++--------------- homeassistant/helpers/entity_registry.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 20e00ec11ed..a0e0d1877fa 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -112,7 +112,7 @@ def websocket_get_entity( return connection.send_message( - websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + websocket_api.result_message(msg["id"], entry.extended_dict) ) @@ -138,7 +138,7 @@ def websocket_get_entities( entries: dict[str, dict[str, Any] | None] = {} for entity_id in entity_ids: entry = registry.entities.get(entity_id) - entries[entity_id] = _entry_ext_dict(entry) if entry else None + entries[entity_id] = entry.extended_dict if entry else None connection.send_message(websocket_api.result_message(msg["id"], entries)) @@ -248,7 +248,7 @@ def websocket_update_entity( ) return - result: dict[str, Any] = {"entity_entry": _entry_ext_dict(entity_entry)} + result: dict[str, Any] = {"entity_entry": entity_entry.extended_dict} if "disabled_by" in changes and changes["disabled_by"] is None: # Enabling an entity requires a config entry reload, or HA restart if ( @@ -289,15 +289,3 @@ def websocket_remove_entity( registry.async_remove(msg["entity_id"]) connection.send_message(websocket_api.result_message(msg["id"])) - - -@callback -def _entry_ext_dict(entry: er.RegistryEntry) -> dict[str, Any]: - """Convert entry to API format.""" - data = entry.as_partial_dict - data["aliases"] = entry.aliases - data["capabilities"] = entry.capabilities - data["device_class"] = entry.device_class - data["original_device_class"] = entry.original_device_class - data["original_icon"] = entry.original_icon - return data diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index bd3077c1d59..a5e27280a5b 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -246,7 +246,7 @@ class RegistryEntry: return None - @property + @cached_property def as_partial_dict(self) -> dict[str, Any]: """Return a partial dict representation of the entry.""" return { @@ -268,6 +268,18 @@ class RegistryEntry: "unique_id": self.unique_id, } + @cached_property + def extended_dict(self) -> dict[str, Any]: + """Return a extended dict representation of the entry.""" + return { + **self.as_partial_dict, + "aliases": self.aliases, + "capabilities": self.capabilities, + "device_class": self.device_class, + "original_device_class": self.original_device_class, + "original_icon": self.original_icon, + } + @cached_property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" From 547c38a515c98db72b59c3b45191ceda3256b5ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Oct 2023 16:19:10 -1000 Subject: [PATCH 442/968] Cache emulated_hue local ip check (#102020) --- .../components/emulated_hue/hue_api.py | 22 ++++++++++++------- tests/components/emulated_hue/test_hue_api.py | 15 ++++++++++++- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 566779671e8..6dfd49c371c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -121,6 +121,12 @@ DIMMABLE_SUPPORT_FEATURES = ( ) +@lru_cache(maxsize=32) +def _remote_is_allowed(address: str) -> bool: + """Check if remote address is allowed.""" + return is_local(ip_address(address)) + + class HueUnauthorizedUser(HomeAssistantView): """Handle requests to find the emulated hue bridge.""" @@ -145,7 +151,7 @@ class HueUsernameView(HomeAssistantView): async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) try: @@ -174,7 +180,7 @@ class HueAllGroupsStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Brilliant Lightpad work.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json({}) @@ -195,7 +201,7 @@ class HueGroupView(HomeAssistantView): def put(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Logitech Pop working.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json( @@ -226,7 +232,7 @@ class HueAllLightsStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) return self.json(create_list_of_entities(self.config, request)) @@ -247,7 +253,7 @@ class HueFullStateView(HomeAssistantView): def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: return self.json(UNAUTHORIZED_USER) @@ -276,7 +282,7 @@ class HueConfigView(HomeAssistantView): def get(self, request: web.Request, username: str = "") -> web.Response: """Process a request to get the configuration.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) json_response = create_config_model(self.config, request) @@ -299,7 +305,7 @@ class HueOneLightStateView(HomeAssistantView): def get(self, request: web.Request, username: str, entity_id: str) -> web.Response: """Process a request to get the state of an individual light.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) hass: core.HomeAssistant = request.app["hass"] @@ -341,7 +347,7 @@ class HueOneLightChangeView(HomeAssistantView): ) -> web.Response: """Process a request to set the state of an individual light.""" assert request.remote is not None - if not is_local(ip_address(request.remote)): + if not _remote_is_allowed(request.remote): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 24acde0709a..fb5ff265497 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -36,9 +36,11 @@ from homeassistant.components.emulated_hue.hue_api import ( HueAllLightsStateView, HueConfigView, HueFullStateView, + HueGroupView, HueOneLightChangeView, HueOneLightStateView, HueUsernameView, + _remote_is_allowed, ) from homeassistant.const import ( ATTR_ENTITY_ID, @@ -232,6 +234,7 @@ def _mock_hue_endpoints( HueOneLightStateView(config).register(hass, web_app, web_app.router) HueOneLightChangeView(config).register(hass, web_app, web_app.router) HueAllGroupsStateView(config).register(hass, web_app, web_app.router) + HueGroupView(config).register(hass, web_app, web_app.router) HueFullStateView(config).register(hass, web_app, web_app.router) HueConfigView(config).register(hass, web_app, web_app.router) @@ -1327,23 +1330,33 @@ async def test_external_ip_blocked(hue_client) -> None: "/api/username/lights/light.ceiling_lights", ] postUrls = ["/api"] - putUrls = ["/api/username/lights/light.ceiling_lights/state"] + putUrls = [ + "/api/username/lights/light.ceiling_lights/state", + "/api/username/groups/0/action", + ] with patch( "homeassistant.components.emulated_hue.hue_api.ip_address", return_value=ip_address("45.45.45.45"), ): for getUrl in getUrls: + _remote_is_allowed.cache_clear() result = await hue_client.get(getUrl) assert result.status == HTTPStatus.UNAUTHORIZED for postUrl in postUrls: + _remote_is_allowed.cache_clear() result = await hue_client.post(postUrl) assert result.status == HTTPStatus.UNAUTHORIZED for putUrl in putUrls: + _remote_is_allowed.cache_clear() result = await hue_client.put(putUrl) assert result.status == HTTPStatus.UNAUTHORIZED + # We are patching inside of a cache so be sure to clear it + # so that the next test is not affected + _remote_is_allowed.cache_clear() + async def test_unauthorized_user_blocked(hue_client) -> None: """Test unauthorized_user blocked.""" From 1f1a27d6a501b133bc36ed5ed686b98a307d2a69 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 15 Oct 2023 04:30:43 +0200 Subject: [PATCH 443/968] Update numpy to 1.26.1 (#102021) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index e166ca716cb..34e9c9f502a 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.26.0"] + "requirements": ["numpy==1.26.1"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index ce519de1b67..005027d562a 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.26.0", "pyiqvia==2022.04.0"] + "requirements": ["numpy==1.26.1", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 3c484385934..c86b78422a9 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/opencv", "iot_class": "local_push", - "requirements": ["numpy==1.26.0", "opencv-python-headless==4.6.0.66"] + "requirements": ["numpy==1.26.1", "opencv-python-headless==4.6.0.66"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 37158aa5fe3..4dc2c39e4eb 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.0"] + "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.1"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index c8682941e28..ca6f663b489 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.26.0", + "numpy==1.26.1", "Pillow==10.0.1" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 0adbf623346..69ace7539a1 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/trend", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.26.0"] + "requirements": ["numpy==1.26.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f4b8bdb73f3..7b8e61b2fc8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==0.18.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.0 +numpy==1.26.1 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated diff --git a/requirements_all.txt b/requirements_all.txt index eb40661478f..f811a966c16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.0 +numpy==1.26.1 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7628ab06bbd..f1fc8c25b7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.0 +numpy==1.26.1 # homeassistant.components.google oauth2client==4.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 78879424098..c8babb1e3ed 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -113,7 +113,7 @@ httpcore==0.18.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.0 +numpy==1.26.1 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated From f9615999db4aafbeb17f87b732d506dd85a059f4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 11:00:50 +0200 Subject: [PATCH 444/968] Add suggested display precision to Withings (#102023) --- homeassistant/components/withings/sensor.py | 8 ++++++++ tests/components/withings/snapshots/test_sensor.ambr | 1 + 2 files changed, 9 insertions(+) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 535dafc763e..48422a4d6e5 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -62,6 +62,7 @@ MEASUREMENT_SENSORS = [ key="weight_kg", measurement_type=MeasurementType.WEIGHT, native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), @@ -70,6 +71,7 @@ MEASUREMENT_SENSORS = [ measurement_type=MeasurementType.FAT_MASS_WEIGHT, translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), @@ -78,6 +80,7 @@ MEASUREMENT_SENSORS = [ measurement_type=MeasurementType.FAT_FREE_MASS, translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), @@ -86,6 +89,7 @@ MEASUREMENT_SENSORS = [ measurement_type=MeasurementType.MUSCLE_MASS, translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), @@ -93,7 +97,9 @@ MEASUREMENT_SENSORS = [ key="bone_mass_kg", measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", + icon="mdi:bone", native_unit_of_measurement=UnitOfMass.KILOGRAMS, + suggested_display_precision=2, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), @@ -102,6 +108,7 @@ MEASUREMENT_SENSORS = [ measurement_type=MeasurementType.HEIGHT, translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=1, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -134,6 +141,7 @@ MEASUREMENT_SENSORS = [ measurement_type=MeasurementType.FAT_RATIO, translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, ), WithingsMeasurementSensorEntityDescription( diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 886cf86f034..c44ef6965f4 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -561,6 +561,7 @@ 'attributes': ReadOnlyDict({ 'device_class': 'weight', 'friendly_name': 'henk Bone mass', + 'icon': 'mdi:bone', 'state_class': , 'unit_of_measurement': , }), From 5b8da03596e5dcf24ac4343df42b2917fb3a5cad Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 14 Oct 2023 23:42:22 -1000 Subject: [PATCH 445/968] Bump aioesphomeapi to 18.0.1 (#102028) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/esphome/manager.py | 2 +- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/conftest.py | 1 + 5 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 41fd60af07d..211404431c0 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -538,7 +538,7 @@ class ESPHomeManager: on_connect=self.on_connect, on_disconnect=self.on_disconnect, zeroconf_instance=self.zeroconf_instance, - name=self.host, + name=entry.data.get(CONF_DEVICE_NAME, self.host), on_connect_error=self.on_connect_error, ) self.reconnect_logic = reconnect_logic diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8a2ede93b3e..463404fae1c 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==17.2.0", + "aioesphomeapi==18.0.1", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index f811a966c16..a7623ad8764 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.2.0 +aioesphomeapi==18.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1fc8c25b7a..791f3f54e8f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==17.2.0 +aioesphomeapi==18.0.1 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index 6b06545a06b..4ff6b503b3c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -129,6 +129,7 @@ def mock_client(mock_device_info) -> APIClient: mock_client.connect = AsyncMock() mock_client.disconnect = AsyncMock() mock_client.list_entities_services = AsyncMock(return_value=([], [])) + mock_client.address = "127.0.0.1" mock_client.api_version = APIVersion(99, 99) with patch("homeassistant.components.esphome.APIClient", mock_client), patch( From 0eb45673646e458b29bedb39f6b1456ac1cc0d11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 12:12:46 +0200 Subject: [PATCH 446/968] Check for port in Withings webhook creation (#102026) Check if port is really 443 --- homeassistant/components/withings/__init__.py | 5 +++-- tests/components/withings/__init__.py | 2 +- tests/components/withings/fixtures/notify_list.json | 4 ++-- tests/components/withings/test_init.py | 8 +++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 05f2db4b18d..548d230f325 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -15,6 +15,7 @@ from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient from aiowithings.util import to_enum import voluptuous as vol +from yarl import URL from homeassistant.components import cloud from homeassistant.components.application_credentials import ( @@ -179,8 +180,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_url = await _async_cloudhook_generate_url(hass, entry) else: webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) - - if not webhook_url.startswith("https://"): + url = URL(webhook_url) + if url.scheme != "https" or url.port != 443: LOGGER.warning( "Webhook not registered - " "https and port 443 is required to register the webhook" diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 4d9a0e841b7..9693d21f162 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -51,7 +51,7 @@ async def setup_integration( if enable_webhooks: await async_process_ha_core_config( hass, - {"external_url": "https://example.local:8123"}, + {"external_url": "https://example.com"}, ) await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json index 5b368a5c979..ef7a99857e4 100644 --- a/tests/components/withings/fixtures/notify_list.json +++ b/tests/components/withings/fixtures/notify_list.json @@ -8,13 +8,13 @@ }, { "appli": 50, - "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null }, { "appli": 51, - "callbackurl": "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", "expires": 2147483647, "comment": null } diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 72b9b495344..9418b032a02 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -130,7 +130,7 @@ async def test_data_manager_webhook_subscription( assert withings.subscribe_notification.call_count == 6 - webhook_url = "https://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + webhook_url = "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" withings.subscribe_notification.assert_any_call( webhook_url, NotificationCategory.WEIGHT @@ -428,12 +428,14 @@ async def test_setup_with_cloud( assert not hass.config_entries.async_entries(DOMAIN) -async def test_setup_without_https( +@pytest.mark.parametrize("url", ["http://example.com", "https://example.com:444"]) +async def test_setup_no_webhook( hass: HomeAssistant, webhook_config_entry: MockConfigEntry, withings: AsyncMock, caplog: pytest.LogCaptureFixture, freezer: FrozenDateTimeFactory, + url: str, ) -> None: """Test if set up with cloud link and without https.""" hass.config.components.add("cloud") @@ -445,7 +447,7 @@ async def test_setup_without_https( ), patch( "homeassistant.components.withings.webhook_generate_url" ) as mock_async_generate_url: - mock_async_generate_url.return_value = "http://example.com" + mock_async_generate_url.return_value = url await setup_integration(hass, webhook_config_entry) await prepare_webhook_setup(hass, freezer) From dc19290271c6f414cecc223233788bc92ce55fd5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 12:14:50 +0200 Subject: [PATCH 447/968] Make Withings test sensors from entity registry (#102025) Test entities from entity registry --- tests/components/withings/test_sensor.py | 29 +++++++----------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 3a937a5f686..c7bd86a219c 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -7,15 +7,11 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.const import DOMAIN -from homeassistant.components.withings.sensor import MEASUREMENT_SENSORS, SLEEP_SENSORS -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import setup_integration -from .conftest import USER_ID from tests.common import ( MockConfigEntry, @@ -24,19 +20,6 @@ from tests.common import ( ) -async def async_get_entity_id( - hass: HomeAssistant, - key: str, - user_id: int, - platform: str, -) -> str | None: - """Get an entity id for a user's attribute.""" - entity_registry = er.async_get(hass) - unique_id = f"withings_{user_id}_{key}" - - return entity_registry.async_get_entity_id(platform, DOMAIN, unique_id) - - @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, @@ -46,10 +29,14 @@ async def test_all_entities( ) -> None: """Test all entities.""" await setup_integration(hass, polling_config_entry) + entity_registry = er.async_get(hass) + entities = er.async_entries_for_config_entry( + entity_registry, polling_config_entry.entry_id + ) - for sensor in MEASUREMENT_SENSORS + SLEEP_SENSORS: - entity_id = await async_get_entity_id(hass, sensor.key, USER_ID, SENSOR_DOMAIN) - assert hass.states.get(entity_id) == snapshot + for entity in entities: + if entity.platform == Platform.SENSOR: + assert hass.states.get(entity.entity_id) == snapshot async def test_update_failed( From 7fe2bfa9908f12e1f90c459a3d1056ccfad6f512 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 05:42:25 -1000 Subject: [PATCH 448/968] Revert "Update numpy to 1.26.1" (#102036) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 34e9c9f502a..e166ca716cb 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@Petro31"], "documentation": "https://www.home-assistant.io/integrations/compensation", "iot_class": "calculated", - "requirements": ["numpy==1.26.1"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 005027d562a..ce519de1b67 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["pyiqvia"], - "requirements": ["numpy==1.26.1", "pyiqvia==2022.04.0"] + "requirements": ["numpy==1.26.0", "pyiqvia==2022.04.0"] } diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index c86b78422a9..3c484385934 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/opencv", "iot_class": "local_push", - "requirements": ["numpy==1.26.1", "opencv-python-headless==4.6.0.66"] + "requirements": ["numpy==1.26.0", "opencv-python-headless==4.6.0.66"] } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 4dc2c39e4eb..37158aa5fe3 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.1"] + "requirements": ["PyTurboJPEG==1.7.1", "ha-av==10.1.1", "numpy==1.26.0"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index ca6f663b489..c8682941e28 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -9,7 +9,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.6", - "numpy==1.26.1", + "numpy==1.26.0", "Pillow==10.0.1" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 69ace7539a1..0adbf623346 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/trend", "iot_class": "calculated", "quality_scale": "internal", - "requirements": ["numpy==1.26.1"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7b8e61b2fc8..f4b8bdb73f3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==0.18.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.1 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated diff --git a/requirements_all.txt b/requirements_all.txt index a7623ad8764..60fcb607bf1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1329,7 +1329,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.1 +numpy==1.26.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 791f3f54e8f..c34b79ac1fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1034,7 +1034,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.26.1 +numpy==1.26.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c8babb1e3ed..78879424098 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -113,7 +113,7 @@ httpcore==0.18.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.26.1 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated From 23e5bc20c29c397c06f5c8d3cead0f3cb387bc28 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 05:54:59 -1000 Subject: [PATCH 449/968] Bump zeroconf to 0.118.0 (#102015) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index a8462e62632..e09d09f588c 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.116.0"] + "requirements": ["zeroconf==0.118.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f4b8bdb73f3..23f006325d8 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.116.0 +zeroconf==0.118.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 60fcb607bf1..624bef4033f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2787,7 +2787,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.116.0 +zeroconf==0.118.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c34b79ac1fd..5018af4ba12 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2081,7 +2081,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.116.0 +zeroconf==0.118.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 05ee28cae5a5dabe0dcf0493923ce9e63755a4e9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 18:00:18 +0200 Subject: [PATCH 450/968] Clean up Withings webhook (#102038) --- homeassistant/components/withings/__init__.py | 11 ++--------- tests/components/withings/test_init.py | 15 ++++----------- 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 548d230f325..9084d80e5c5 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -10,7 +10,7 @@ import contextlib from datetime import timedelta from typing import Any -from aiohttp.hdrs import METH_HEAD, METH_POST +from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response from aiowithings import NotificationCategory, WithingsClient from aiowithings.util import to_enum @@ -198,6 +198,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: webhook_name, entry.data[CONF_WEBHOOK_ID], get_webhook_handler(coordinators), + allowed_methods=[METH_POST], ) await async_subscribe_webhooks(client, webhook_url) @@ -325,14 +326,6 @@ def get_webhook_handler( async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: - # Handle http head calls to the path. - # When creating a notify subscription, Withings will check that the endpoint is running by sending a HEAD request. - if request.method == METH_HEAD: - return Response() - - if request.method != METH_POST: - return json_message_response("Invalid method", message_code=2) - # Handle http post calls to the path. if not request.body_exists: return json_message_response("No request body", message_code=12) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 9418b032a02..baec7e92ea0 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -4,6 +4,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from urllib.parse import urlparse +from aiohttp.hdrs import METH_HEAD from aiowithings import ( NotificationCategory, WithingsAuthenticationFailedError, @@ -173,27 +174,19 @@ async def test_webhook_subscription_polling_config( assert withings.list_notification_configurations.call_count == 0 -@pytest.mark.parametrize( - "method", - [ - "PUT", - "HEAD", - ], -) -async def test_requests( +async def test_head_request( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, - method: str, ) -> None: - """Test we handle request methods Withings sends.""" + """Test we handle head requests Withings sends.""" await setup_integration(hass, webhook_config_entry) client = await hass_client_no_auth() webhook_url = async_generate_url(hass, WEBHOOK_ID) response = await client.request( - method=method, + method=METH_HEAD, path=urlparse(webhook_url).path, ) assert response.status == 200 From dcb5faa305b5889f4be8ec1967a8d9cbd3021240 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 18:00:52 +0200 Subject: [PATCH 451/968] Dynamically add sensors for new measurements in Withings (#102022) * Dynamically add sensors for new data points in Withings * Dynamically add sensors for new data points in Withings * Add test * Change docstring * Store new measurements * Fix feedback * Add test back * Add test back * Add test back --- homeassistant/components/withings/sensor.py | 63 +++++++++++++------ .../withings/fixtures/get_meas_1.json | 5 -- tests/components/withings/test_sensor.py | 31 +++++++++ 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 48422a4d6e5..b5b77144281 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -57,8 +57,10 @@ class WithingsMeasurementSensorEntityDescription( """Immutable class for describing withings data.""" -MEASUREMENT_SENSORS = [ - WithingsMeasurementSensorEntityDescription( +MEASUREMENT_SENSORS: dict[ + MeasurementType, WithingsMeasurementSensorEntityDescription +] = { + MeasurementType.WEIGHT: WithingsMeasurementSensorEntityDescription( key="weight_kg", measurement_type=MeasurementType.WEIGHT, native_unit_of_measurement=UnitOfMass.KILOGRAMS, @@ -66,7 +68,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.FAT_MASS_WEIGHT: WithingsMeasurementSensorEntityDescription( key="fat_mass_kg", measurement_type=MeasurementType.FAT_MASS_WEIGHT, translation_key="fat_mass", @@ -75,7 +77,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.FAT_FREE_MASS: WithingsMeasurementSensorEntityDescription( key="fat_free_mass_kg", measurement_type=MeasurementType.FAT_FREE_MASS, translation_key="fat_free_mass", @@ -84,7 +86,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.MUSCLE_MASS: WithingsMeasurementSensorEntityDescription( key="muscle_mass_kg", measurement_type=MeasurementType.MUSCLE_MASS, translation_key="muscle_mass", @@ -93,7 +95,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.BONE_MASS: WithingsMeasurementSensorEntityDescription( key="bone_mass_kg", measurement_type=MeasurementType.BONE_MASS, translation_key="bone_mass", @@ -103,7 +105,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.HEIGHT: WithingsMeasurementSensorEntityDescription( key="height_m", measurement_type=MeasurementType.HEIGHT, translation_key="height", @@ -113,14 +115,14 @@ MEASUREMENT_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.TEMPERATURE: WithingsMeasurementSensorEntityDescription( key="temperature_c", measurement_type=MeasurementType.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.BODY_TEMPERATURE: WithingsMeasurementSensorEntityDescription( key="body_temperature_c", measurement_type=MeasurementType.BODY_TEMPERATURE, translation_key="body_temperature", @@ -128,7 +130,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.SKIN_TEMPERATURE: WithingsMeasurementSensorEntityDescription( key="skin_temperature_c", measurement_type=MeasurementType.SKIN_TEMPERATURE, translation_key="skin_temperature", @@ -136,7 +138,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.FAT_RATIO: WithingsMeasurementSensorEntityDescription( key="fat_ratio_pct", measurement_type=MeasurementType.FAT_RATIO, translation_key="fat_ratio", @@ -144,21 +146,21 @@ MEASUREMENT_SENSORS = [ suggested_display_precision=2, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.DIASTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( key="diastolic_blood_pressure_mmhg", measurement_type=MeasurementType.DIASTOLIC_BLOOD_PRESSURE, translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.SYSTOLIC_BLOOD_PRESSURE: WithingsMeasurementSensorEntityDescription( key="systolic_blood_pressure_mmhg", measurement_type=MeasurementType.SYSTOLIC_BLOOD_PRESSURE, translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.HEART_RATE: WithingsMeasurementSensorEntityDescription( key="heart_pulse_bpm", measurement_type=MeasurementType.HEART_RATE, translation_key="heart_pulse", @@ -166,14 +168,14 @@ MEASUREMENT_SENSORS = [ icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.SP02: WithingsMeasurementSensorEntityDescription( key="spo2_pct", measurement_type=MeasurementType.SP02, translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.HYDRATION: WithingsMeasurementSensorEntityDescription( key="hydration", measurement_type=MeasurementType.HYDRATION, translation_key="hydration", @@ -183,7 +185,7 @@ MEASUREMENT_SENSORS = [ state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), - WithingsMeasurementSensorEntityDescription( + MeasurementType.PULSE_WAVE_VELOCITY: WithingsMeasurementSensorEntityDescription( key="pulse_wave_velocity", measurement_type=MeasurementType.PULSE_WAVE_VELOCITY, translation_key="pulse_wave_velocity", @@ -191,7 +193,7 @@ MEASUREMENT_SENSORS = [ device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, ), -] +} @dataclass @@ -371,11 +373,32 @@ async def async_setup_entry( measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[ DOMAIN ][entry.entry_id][MEASUREMENT_COORDINATOR] + + current_measurement_types = set(measurement_coordinator.data.keys()) + entities: list[SensorEntity] = [] entities.extend( - WithingsMeasurementSensor(measurement_coordinator, attribute) - for attribute in MEASUREMENT_SENSORS + WithingsMeasurementSensor( + measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] + ) + for measurement_type in measurement_coordinator.data + if measurement_type in MEASUREMENT_SENSORS ) + + def _async_measurement_listener() -> None: + """Listen for new measurements and add sensors if they did not exist.""" + received_measurement_types = set(measurement_coordinator.data.keys()) + new_measurement_types = received_measurement_types - current_measurement_types + if new_measurement_types: + current_measurement_types.update(new_measurement_types) + async_add_entities( + WithingsMeasurementSensor( + measurement_coordinator, MEASUREMENT_SENSORS[measurement_type] + ) + for measurement_type in new_measurement_types + ) + + measurement_coordinator.async_add_listener(_async_measurement_listener) sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ][SLEEP_COORDINATOR] diff --git a/tests/components/withings/fixtures/get_meas_1.json b/tests/components/withings/fixtures/get_meas_1.json index a1415695746..74148706bd7 100644 --- a/tests/components/withings/fixtures/get_meas_1.json +++ b/tests/components/withings/fixtures/get_meas_1.json @@ -14,11 +14,6 @@ "unit": 0, "value": 71 }, - { - "type": 8, - "unit": 0, - "value": 5 - }, { "type": 5, "unit": 0, diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index c7bd86a219c..96a0397257d 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -17,6 +17,7 @@ from tests.common import ( MockConfigEntry, async_fire_time_changed, load_json_array_fixture, + load_json_object_fixture, ) @@ -103,3 +104,33 @@ async def test_update_updates_incrementally( assert state is not None assert state.state == "71" assert len(withings.get_measurement_in_period.call_args_list) == 1 + + +async def test_update_new_measurement_creates_new_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching a new measurement will add a new sensor.""" + meas_json = load_json_array_fixture("withings/get_meas_1.json") + measurement_groups = [ + MeasurementGroup.from_api(measurement) for measurement in meas_json + ] + withings.get_measurement_in_period.return_value = measurement_groups + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_fat_mass") is None + + meas_json = load_json_object_fixture("withings/get_meas.json") + measurement_groups = [ + MeasurementGroup.from_api(measurement) + for measurement in meas_json["measuregrps"] + ] + withings.get_measurement_in_period.return_value = measurement_groups + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_fat_mass") is not None From 148087a1c91f783c477feab1873c9c15512e1b07 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 18:04:03 +0200 Subject: [PATCH 452/968] Mark Withings as cloud push (#102040) --- homeassistant/components/withings/manifest.json | 4 ++-- homeassistant/generated/integrations.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 9ed7dea08ad..ca1acae0a4b 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", - "iot_class": "cloud_polling", - "loggers": ["withings_api"], + "iot_class": "cloud_push", + "loggers": ["aiowithings"], "requirements": ["aiowithings==0.4.4"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bb36eaaad1f..71fddb0a18a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6412,7 +6412,7 @@ "name": "Withings", "integration_type": "hub", "config_flow": true, - "iot_class": "cloud_polling" + "iot_class": "cloud_push" }, "wiz": { "name": "WiZ", From 264eef8dd88777588883f16de4d25873a529deb0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 15 Oct 2023 18:14:38 +0200 Subject: [PATCH 453/968] Allow to remove devices in Sensibo (#101890) --- homeassistant/components/sensibo/__init__.py | 17 +++++++ tests/components/sensibo/test_init.py | 50 +++++++++++++++++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index 29730216899..923bc3eae1f 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -3,9 +3,12 @@ from __future__ import annotations from pysensibo.exceptions import AuthenticationError +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator @@ -53,3 +56,17 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return True + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> bool: + """Remove Sensibo config entry from a device.""" + entity_registry = er.async_get(hass) + for identifier in device.identifiers: + if identifier[0] == DOMAIN and entity_registry.async_get_entity_id( + CLIMATE_DOMAIN, DOMAIN, identifier[1] + ): + return False + + return True diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 505816e3f41..90dbcd86a96 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -8,12 +8,15 @@ from pysensibo.model import SensiboData from homeassistant import config_entries from homeassistant.components.sensibo.const import DOMAIN from homeassistant.components.sensibo.util import NoUsernameError -from homeassistant.config_entries import SOURCE_USER +from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import ENTRY_CONFIG from tests.common import MockConfigEntry +from tests.typing import WebSocketGenerator async def test_setup_entry(hass: HomeAssistant, get_data: SensiboData) -> None: @@ -131,3 +134,48 @@ async def test_unload_entry(hass: HomeAssistant, get_data: SensiboData) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices( + hass: HomeAssistant, + load_int: ConfigEntry, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.entities["climate.hallway"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, load_int.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=load_int.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, load_int.entry_id + ) + is True + ) From e427fc511b3e77373e52ff194fff0a2c9db292b5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 06:14:51 -1000 Subject: [PATCH 454/968] Bump SQLAlchemy to 2.0.22 (#102033) --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index f40797fe38c..4557e885570 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "SQLAlchemy==2.0.21", + "SQLAlchemy==2.0.22", "fnv-hash-fast==0.4.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 7424807c804..e570f6bac0b 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["SQLAlchemy==2.0.21"] + "requirements": ["SQLAlchemy==2.0.22"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 23f006325d8..db9e6fd9913 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -46,7 +46,7 @@ pyudev==0.23.2 PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 624bef4033f..f96fc8c21fa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -132,7 +132,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5018af4ba12..052c7a26e47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -119,7 +119,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.21 +SQLAlchemy==2.0.22 # homeassistant.components.tami4 Tami4EdgeAPI==2.1 From d237ab6d67015d1d9e41ec6b22bada61ae13a1e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 06:49:59 -1000 Subject: [PATCH 455/968] Bump HAP-python to 4.9.0 (#102055) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 67f99ad5f8b..563654f1dc9 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.8.0", + "HAP-python==4.9.0", "fnv-hash-fast==0.4.1", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index f96fc8c21fa..a07415e754d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.8.0 +HAP-python==4.9.0 # homeassistant.components.tasmota HATasmota==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 052c7a26e47..983e2135b07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.8.0 +HAP-python==4.9.0 # homeassistant.components.tasmota HATasmota==0.7.3 From 8c14824bd5913175f5d48aff8023e0a1943987c5 Mon Sep 17 00:00:00 2001 From: Maximilian <43999966+DeerMaximum@users.noreply.github.com> Date: Sun, 15 Oct 2023 17:01:05 +0000 Subject: [PATCH 456/968] Bump pynina to 0.3.3 (#101960) --- homeassistant/components/nina/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nina/manifest.json b/homeassistant/components/nina/manifest.json index df09d168827..1bf670aedf0 100644 --- a/homeassistant/components/nina/manifest.json +++ b/homeassistant/components/nina/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/nina", "iot_class": "cloud_polling", "loggers": ["pynina"], - "requirements": ["PyNINA==0.3.2"] + "requirements": ["PyNINA==0.3.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index a07415e754d..44250622a90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -80,7 +80,7 @@ PyMetno==0.11.0 PyMicroBot==0.0.9 # homeassistant.components.nina -PyNINA==0.3.2 +PyNINA==0.3.3 # homeassistant.components.mobile_app # homeassistant.components.owntracks diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 983e2135b07..0e22f48577e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -70,7 +70,7 @@ PyMetno==0.11.0 PyMicroBot==0.0.9 # homeassistant.components.nina -PyNINA==0.3.2 +PyNINA==0.3.3 # homeassistant.components.mobile_app # homeassistant.components.owntracks From 1a348babd49e91d28e376987bf44dd3bb14f3cb3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 19:08:57 +0200 Subject: [PATCH 457/968] Add Withings to strict-typing (#101761) * Add Withings to strict-typing * Rebase --- .strict-typing | 1 + homeassistant/components/withings/__init__.py | 7 +++++-- mypy.ini | 10 ++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2adadffea49..97e3f577849 100644 --- a/.strict-typing +++ b/.strict-typing @@ -361,6 +361,7 @@ homeassistant.components.webostv.* homeassistant.components.websocket_api.* homeassistant.components.wemo.* homeassistant.components.whois.* +homeassistant.components.withings.* homeassistant.components.wiz.* homeassistant.components.wled.* homeassistant.components.worldclock.* diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 9084d80e5c5..ba58ee650be 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -8,7 +8,7 @@ import asyncio from collections.abc import Awaitable, Callable import contextlib from datetime import timedelta -from typing import Any +from typing import TYPE_CHECKING, Any from aiohttp.hdrs import METH_POST from aiohttp.web import Request, Response @@ -148,7 +148,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _refresh_token() -> str: await oauth_session.async_ensure_token_valid() - return oauth_session.token[CONF_ACCESS_TOKEN] + token = oauth_session.token[CONF_ACCESS_TOKEN] + if TYPE_CHECKING: + assert isinstance(token, str) + return token client.refresh_token_function = _refresh_token coordinators: dict[str, WithingsDataUpdateCoordinator] = { diff --git a/mypy.ini b/mypy.ini index 93fe5326e98..43ec39ebc56 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3373,6 +3373,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.withings.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.wiz.*] check_untyped_defs = true disallow_incomplete_defs = true From 36fcf198b1fcb142b88de24e175c7d9b59cfaaf8 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Sun, 15 Oct 2023 11:11:34 -0600 Subject: [PATCH 458/968] Adjust WeatherFlow air density sensor device class and unit (#101777) --- homeassistant/components/weatherflow/sensor.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/weatherflow/sensor.py b/homeassistant/components/weatherflow/sensor.py index cd648fda360..bc5d38e99e5 100644 --- a/homeassistant/components/weatherflow/sensor.py +++ b/homeassistant/components/weatherflow/sensor.py @@ -21,7 +21,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, DEGREE, LIGHT_LUX, PERCENTAGE, @@ -80,11 +79,10 @@ SENSORS: tuple[WeatherFlowSensorEntityDescription, ...] = ( WeatherFlowSensorEntityDescription( key="air_density", translation_key="air_density", - native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement="kg/m³", state_class=SensorStateClass.MEASUREMENT, - suggested_display_precision=3, - raw_data_conv_fn=lambda raw_data: raw_data.m * 1000000, + suggested_display_precision=5, + raw_data_conv_fn=lambda raw_data: raw_data.magnitude, ), WeatherFlowSensorEntityDescription( key="air_temperature", From 2c3067b9c2f0f81d9a21a203d134d6cf30b0ce9d Mon Sep 17 00:00:00 2001 From: Whitney Young Date: Sun, 15 Oct 2023 10:48:47 -0700 Subject: [PATCH 459/968] Fix date observed is not sent by AirNow (#101921) (#101977) * Fix mixed up keys These were accidentally the wrong values, but never passed on to the end user. * Fix date observed is not sent by AirNow (#101921) --- homeassistant/components/airnow/const.py | 5 +++-- homeassistant/components/airnow/coordinator.py | 2 ++ homeassistant/components/airnow/sensor.py | 12 ++++++++++++ .../airnow/snapshots/test_diagnostics.ambr | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py index 34b1f4392bc..137c8f1efad 100644 --- a/homeassistant/components/airnow/const.py +++ b/homeassistant/components/airnow/const.py @@ -9,8 +9,9 @@ ATTR_API_CAT_DESCRIPTION = "Name" ATTR_API_O3 = "O3" ATTR_API_PM25 = "PM2.5" ATTR_API_POLLUTANT = "Pollutant" -ATTR_API_REPORT_DATE = "HourObserved" -ATTR_API_REPORT_HOUR = "DateObserved" +ATTR_API_REPORT_DATE = "DateObserved" +ATTR_API_REPORT_HOUR = "HourObserved" +ATTR_API_REPORT_TZ = "LocalTimeZone" ATTR_API_STATE = "StateCode" ATTR_API_STATION = "ReportingArea" ATTR_API_STATION_LATITUDE = "Latitude" diff --git a/homeassistant/components/airnow/coordinator.py b/homeassistant/components/airnow/coordinator.py index 7a4ad46cd82..e89afc2f7ce 100644 --- a/homeassistant/components/airnow/coordinator.py +++ b/homeassistant/components/airnow/coordinator.py @@ -20,6 +20,7 @@ from .const import ( ATTR_API_POLLUTANT, ATTR_API_REPORT_DATE, ATTR_API_REPORT_HOUR, + ATTR_API_REPORT_TZ, ATTR_API_STATE, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, @@ -83,6 +84,7 @@ class AirNowDataUpdateCoordinator(DataUpdateCoordinator): # Copy Report Details data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE] data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR] + data[ATTR_API_REPORT_TZ] = obv[ATTR_API_REPORT_TZ] # Copy Station Details data[ATTR_API_STATE] = obv[ATTR_API_STATE] diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index c83232c273a..f9d35d50810 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime from typing import Any from homeassistant.components.sensor import ( @@ -13,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_TIME, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, ) @@ -21,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import get_time_zone from . import AirNowDataUpdateCoordinator from .const import ( @@ -29,6 +32,9 @@ from .const import ( ATTR_API_AQI_LEVEL, ATTR_API_O3, ATTR_API_PM25, + ATTR_API_REPORT_DATE, + ATTR_API_REPORT_HOUR, + ATTR_API_REPORT_TZ, ATTR_API_STATION, ATTR_API_STATION_LATITUDE, ATTR_API_STATION_LONGITUDE, @@ -78,6 +84,12 @@ SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( extra_state_attributes_fn=lambda data: { ATTR_DESCR: data[ATTR_API_AQI_DESCRIPTION], ATTR_LEVEL: data[ATTR_API_AQI_LEVEL], + ATTR_TIME: datetime.strptime( + f"{data[ATTR_API_REPORT_DATE]} {data[ATTR_API_REPORT_HOUR]}", + "%Y-%m-%d %H", + ) + .replace(tzinfo=get_time_zone(data[ATTR_API_REPORT_TZ])) + .isoformat(), }, ), AirNowEntityDescription( diff --git a/tests/components/airnow/snapshots/test_diagnostics.ambr b/tests/components/airnow/snapshots/test_diagnostics.ambr index 8041cb55692..80c6de427ca 100644 --- a/tests/components/airnow/snapshots/test_diagnostics.ambr +++ b/tests/components/airnow/snapshots/test_diagnostics.ambr @@ -8,6 +8,7 @@ 'DateObserved': '2020-12-20', 'HourObserved': 15, 'Latitude': '**REDACTED**', + 'LocalTimeZone': 'PST', 'Longitude': '**REDACTED**', 'O3': 0.048, 'PM10': 12, From 6f5a72edf235e84fab4344aad0b96d1293290213 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 15 Oct 2023 11:24:41 -0700 Subject: [PATCH 460/968] Bump gcal_sync to 5.0.0 (#102010) * Bump gcal_sync to 5.0.0 * Update snapshots --- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/google/snapshots/test_diagnostics.ambr | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index d5329598655..509100a5174 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/calendar.google", "iot_class": "cloud_polling", "loggers": ["googleapiclient"], - "requirements": ["gcal-sync==4.1.4", "oauth2client==4.1.3"] + "requirements": ["gcal-sync==5.0.0", "oauth2client==4.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 44250622a90..ddc421e0744 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -854,7 +854,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==4.1.4 +gcal-sync==5.0.0 # homeassistant.components.geniushub geniushub-client==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e22f48577e..0e4c4459b3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -679,7 +679,7 @@ gardena-bluetooth==1.4.0 gassist-text==0.0.10 # homeassistant.components.google -gcal-sync==4.1.4 +gcal-sync==5.0.0 # homeassistant.components.geocaching geocachingapi==0.2.1 diff --git a/tests/components/google/snapshots/test_diagnostics.ambr b/tests/components/google/snapshots/test_diagnostics.ambr index c19d3a82f74..6ac9d6c508d 100644 --- a/tests/components/google/snapshots/test_diagnostics.ambr +++ b/tests/components/google/snapshots/test_diagnostics.ambr @@ -22,6 +22,11 @@ 'recurrence': list([ ]), 'recurring_event_id': None, + 'reminders': dict({ + 'overrides': list([ + ]), + 'use_default': True, + }), 'start': dict({ 'date': None, 'date_time': '2023-03-13T12:00:00-07:00', @@ -50,6 +55,11 @@ 'RRULE:FREQ=WEEKLY', ]), 'recurring_event_id': None, + 'reminders': dict({ + 'overrides': list([ + ]), + 'use_default': True, + }), 'start': dict({ 'date': '2022-10-08', 'date_time': None, From b4e4a98f178638ed156d57f229e194aa273fc841 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 20:29:20 +0200 Subject: [PATCH 461/968] Add diagnostics to Withings (#102066) --- .../components/withings/coordinator.py | 2 + .../components/withings/diagnostics.py | 45 +++++++++++ .../withings/snapshots/test_diagnostics.ambr | 79 ++++++++++++++++++ tests/components/withings/test_diagnostics.py | 80 +++++++++++++++++++ 4 files changed, 206 insertions(+) create mode 100644 homeassistant/components/withings/diagnostics.py create mode 100644 tests/components/withings/snapshots/test_diagnostics.ambr create mode 100644 tests/components/withings/test_diagnostics.py diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index c5192ba3466..ac320aae3ae 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -34,6 +34,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): config_entry: ConfigEntry _default_update_interval: timedelta | None = UPDATE_INTERVAL _last_valid_update: datetime | None = None + webhooks_connected: bool = False def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: """Initialize the Withings data coordinator.""" @@ -45,6 +46,7 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): def webhook_subscription_listener(self, connected: bool) -> None: """Call when webhook status changed.""" + self.webhooks_connected = connected if connected: self.update_interval = None else: diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py new file mode 100644 index 00000000000..2424452d0f5 --- /dev/null +++ b/homeassistant/components/withings/diagnostics.py @@ -0,0 +1,45 @@ +"""Diagnostics support for Withings.""" +from __future__ import annotations + +from typing import Any + +from yarl import URL + +from homeassistant.components.webhook import async_generate_url as webhook_generate_url +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import HomeAssistant + +from . import ( + CONF_CLOUDHOOK_URL, + WithingsMeasurementDataUpdateCoordinator, + WithingsSleepDataUpdateCoordinator, +) +from .const import DOMAIN, MEASUREMENT_COORDINATOR, SLEEP_COORDINATOR + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + webhook_url = webhook_generate_url(hass, entry.data[CONF_WEBHOOK_ID]) + url = URL(webhook_url) + has_valid_external_webhook_url = url.scheme == "https" and url.port == 443 + + has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data + + measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[ + DOMAIN + ][entry.entry_id][MEASUREMENT_COORDINATOR] + sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][SLEEP_COORDINATOR] + + return { + "has_valid_external_webhook_url": has_valid_external_webhook_url, + "has_cloudhooks": has_cloudhooks, + "webhooks_connected": measurement_coordinator.webhooks_connected, + "received_measurements": list(measurement_coordinator.data.keys()), + "received_sleep_data": sleep_coordinator.data is not None, + } diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..c65c321a5ef --- /dev/null +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -0,0 +1,79 @@ +# serializer version: 1 +# name: test_diagnostics_cloudhook_instance + dict({ + 'has_cloudhooks': True, + 'has_valid_external_webhook_url': True, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + ]), + 'received_sleep_data': True, + 'webhooks_connected': True, + }) +# --- +# name: test_diagnostics_polling_instance + dict({ + 'has_cloudhooks': False, + 'has_valid_external_webhook_url': False, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + ]), + 'received_sleep_data': True, + 'webhooks_connected': False, + }) +# --- +# name: test_diagnostics_webhook_instance + dict({ + 'has_cloudhooks': False, + 'has_valid_external_webhook_url': True, + 'received_measurements': list([ + 1, + 8, + 5, + 76, + 88, + 4, + 12, + 71, + 73, + 6, + 9, + 10, + 11, + 54, + 77, + 91, + ]), + 'received_sleep_data': True, + 'webhooks_connected': True, + }) +# --- diff --git a/tests/components/withings/test_diagnostics.py b/tests/components/withings/test_diagnostics.py new file mode 100644 index 00000000000..bb5c93e1f09 --- /dev/null +++ b/tests/components/withings/test_diagnostics.py @@ -0,0 +1,80 @@ +"""Tests for the diagnostics data provided by the Withings integration.""" +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.components.withings import prepare_webhook_setup, setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics_polling_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, polling_config_entry, False) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, polling_config_entry) + == snapshot + ) + + +async def test_diagnostics_webhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostics.""" + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, webhook_config_entry) + == snapshot + ) + + +async def test_diagnostics_cloudhook_instance( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + withings: AsyncMock, + webhook_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test diagnostics.""" + with patch( + "homeassistant.components.cloud.async_is_logged_in", return_value=True + ), patch( + "homeassistant.components.cloud.async_is_connected", return_value=True + ), patch( + "homeassistant.components.cloud.async_active_subscription", return_value=True + ), patch( + "homeassistant.components.cloud.async_create_cloudhook", + return_value="https://hooks.nabu.casa/ABCD", + ), patch( + "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation", + ), patch( + "homeassistant.components.cloud.async_delete_cloudhook" + ), patch( + "homeassistant.components.withings.webhook_generate_url" + ): + await setup_integration(hass, webhook_config_entry) + await prepare_webhook_setup(hass, freezer) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, webhook_config_entry) + == snapshot + ) From b95060df99990ff81413c6ce054dceccad80a7e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 21:04:01 +0200 Subject: [PATCH 462/968] Promote Withings to Platinum quality (#102069) --- homeassistant/components/withings/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index ca1acae0a4b..3adbdbed0b3 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -8,5 +8,6 @@ "documentation": "https://www.home-assistant.io/integrations/withings", "iot_class": "cloud_push", "loggers": ["aiowithings"], + "quality_scale": "platinum", "requirements": ["aiowithings==0.4.4"] } From 471d1abe479951b445fdf7ff5da50c0ed0a572e1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 21:38:30 +0200 Subject: [PATCH 463/968] Add more measurement sensors to Withings (#102074) --- homeassistant/components/withings/sensor.py | 32 + .../components/withings/strings.json | 12 + .../withings/fixtures/get_meas.json | 20 + .../withings/snapshots/test_diagnostics.ambr | 12 + .../withings/snapshots/test_sensor.ambr | 990 +++--------------- tests/components/withings/test_sensor.py | 3 +- 6 files changed, 219 insertions(+), 850 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index b5b77144281..e5096701b48 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -193,6 +193,38 @@ MEASUREMENT_SENSORS: dict[ device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, ), + MeasurementType.VO2: WithingsMeasurementSensorEntityDescription( + key="vo2_max", + measurement_type=MeasurementType.VO2, + translation_key="vo2_max", + native_unit_of_measurement="ml/min/kg", + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.EXTRACELLULAR_WATER: WithingsMeasurementSensorEntityDescription( + key="extracellular_water", + measurement_type=MeasurementType.EXTRACELLULAR_WATER, + translation_key="extracellular_water", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.INTRACELLULAR_WATER: WithingsMeasurementSensorEntityDescription( + key="intracellular_water", + measurement_type=MeasurementType.INTRACELLULAR_WATER, + translation_key="intracellular_water", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), + MeasurementType.VASCULAR_AGE: WithingsMeasurementSensorEntityDescription( + key="vascular_age", + measurement_type=MeasurementType.VASCULAR_AGE, + translation_key="vascular_age", + entity_registry_enabled_default=False, + ), } diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a9ba69ad045..020509064b3 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -72,6 +72,18 @@ "pulse_wave_velocity": { "name": "Pulse wave velocity" }, + "vo2_max": { + "name": "VO2 max" + }, + "extracellular_water": { + "name": "Extracellular water" + }, + "intracellular_water": { + "name": "Intracellular water" + }, + "vascular_age": { + "name": "Vascular age" + }, "breathing_disturbances_intensity": { "name": "Breathing disturbances intensity" }, diff --git a/tests/components/withings/fixtures/get_meas.json b/tests/components/withings/fixtures/get_meas.json index 1776ba8ff8a..d473b61c274 100644 --- a/tests/components/withings/fixtures/get_meas.json +++ b/tests/components/withings/fixtures/get_meas.json @@ -93,6 +93,26 @@ "type": 91, "unit": 0, "value": 100 + }, + { + "type": 123, + "unit": 0, + "value": 100 + }, + { + "type": 155, + "unit": 0, + "value": 100 + }, + { + "type": 168, + "unit": 0, + "value": 100 + }, + { + "type": 169, + "unit": 0, + "value": 100 } ], "modelid": 45, diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index c65c321a5ef..3b6a5390bd6 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -20,6 +20,10 @@ 54, 77, 91, + 123, + 155, + 168, + 169, ]), 'received_sleep_data': True, 'webhooks_connected': True, @@ -46,6 +50,10 @@ 54, 77, 91, + 123, + 155, + 168, + 169, ]), 'received_sleep_data': True, 'webhooks_connected': False, @@ -72,6 +80,10 @@ 54, 77, 91, + 123, + 155, + 168, + 169, ]), 'received_sleep_data': True, 'webhooks_connected': True, diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index c44ef6965f4..6a0bee0fbc8 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -120,62 +120,57 @@ # name: test_all_entities.16 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Breathing disturbances intensity', + 'friendly_name': 'henk VO2 max', 'state_class': , + 'unit_of_measurement': 'ml/min/kg', }), 'context': , - 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'entity_id': 'sensor.henk_vo2_max', 'last_changed': , 'last_updated': , - 'state': '10', + 'state': '100', }) # --- # name: test_all_entities.17 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Deep sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , + 'friendly_name': 'henk Vascular age', }), 'context': , - 'entity_id': 'sensor.henk_deep_sleep', + 'entity_id': 'sensor.henk_vascular_age', 'last_changed': , 'last_updated': , - 'state': '26220', + 'state': '100', }) # --- # name: test_all_entities.18 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to sleep', - 'icon': 'mdi:sleep', + 'device_class': 'weight', + 'friendly_name': 'henk Extracellular water', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_time_to_sleep', + 'entity_id': 'sensor.henk_extracellular_water', 'last_changed': , 'last_updated': , - 'state': '780', + 'state': '100', }) # --- # name: test_all_entities.19 StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to wakeup', - 'icon': 'mdi:sleep-off', + 'device_class': 'weight', + 'friendly_name': 'henk Intracellular water', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_time_to_wakeup', + 'entity_id': 'sensor.henk_intracellular_water', 'last_changed': , 'last_updated': , - 'state': '996', + 'state': '100', }) # --- # name: test_all_entities.2 @@ -194,6 +189,67 @@ }) # --- # name: test_all_entities.20 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities.21 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Deep sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_deep_sleep', + 'last_changed': , + 'last_updated': , + 'state': '26220', + }) +# --- +# name: test_all_entities.22 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_sleep', + 'last_changed': , + 'last_updated': , + 'state': '780', + }) +# --- +# name: test_all_entities.23 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to wakeup', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_wakeup', + 'last_changed': , + 'last_updated': , + 'state': '996', + }) +# --- +# name: test_all_entities.24 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average heart rate', @@ -208,7 +264,7 @@ 'state': '83', }) # --- -# name: test_all_entities.21 +# name: test_all_entities.25 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Maximum heart rate', @@ -223,7 +279,7 @@ 'state': '108', }) # --- -# name: test_all_entities.22 +# name: test_all_entities.26 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Minimum heart rate', @@ -238,7 +294,7 @@ 'state': '58', }) # --- -# name: test_all_entities.23 +# name: test_all_entities.27 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -254,7 +310,7 @@ 'state': '58440', }) # --- -# name: test_all_entities.24 +# name: test_all_entities.28 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -270,7 +326,7 @@ 'state': '17280', }) # --- -# name: test_all_entities.25 +# name: test_all_entities.29 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average respiratory rate', @@ -284,62 +340,6 @@ 'state': '14', }) # --- -# name: test_all_entities.26 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum respiratory rate', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.henk_maximum_respiratory_rate', - 'last_changed': , - 'last_updated': , - 'state': '20', - }) -# --- -# name: test_all_entities.27 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Minimum respiratory rate', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.henk_minimum_respiratory_rate', - 'last_changed': , - 'last_updated': , - 'state': '10', - }) -# --- -# name: test_all_entities.28 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Sleep score', - 'icon': 'mdi:medal', - 'state_class': , - 'unit_of_measurement': 'points', - }), - 'context': , - 'entity_id': 'sensor.henk_sleep_score', - 'last_changed': , - 'last_updated': , - 'state': '90', - }) -# --- -# name: test_all_entities.29 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Snoring', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.henk_snoring', - 'last_changed': , - 'last_updated': , - 'state': '1044', - }) -# --- # name: test_all_entities.3 StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -356,6 +356,62 @@ }) # --- # name: test_all_entities.30 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities.31 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities.32 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Sleep score', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.henk_sleep_score', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_all_entities.33 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring', + 'last_changed': , + 'last_updated': , + 'state': '1044', + }) +# --- +# name: test_all_entities.34 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Snoring episode count', @@ -368,7 +424,7 @@ 'state': '87', }) # --- -# name: test_all_entities.31 +# name: test_all_entities.35 StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Wakeup count', @@ -383,7 +439,7 @@ 'state': '8', }) # --- -# name: test_all_entities.32 +# name: test_all_entities.36 StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'duration', @@ -399,163 +455,6 @@ 'state': '3468', }) # --- -# name: test_all_entities.33 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_breathing_disturbances_intensity henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_breathing_disturbances_intensity_henk', - 'last_changed': , - 'last_updated': , - 'state': '160.0', - }) -# --- -# name: test_all_entities.34 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_deep_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_deep_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.35 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_deep_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_deep_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '322', - }) -# --- -# name: test_all_entities.36 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_tosleep_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_tosleep_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.37 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_tosleep_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_tosleep_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '162.0', - }) -# --- -# name: test_all_entities.38 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_towakeup_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_towakeup_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.39 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_towakeup_duration_seconds henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_towakeup_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '163.0', - }) -# --- # name: test_all_entities.4 StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -572,243 +471,6 @@ 'state': '10', }) # --- -# name: test_all_entities.40 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_average_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_average_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.41 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_average_bpm henk', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_average_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '164.0', - }) -# --- -# name: test_all_entities.42 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_max_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_max_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.43 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_max_bpm henk', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_max_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '165.0', - }) -# --- -# name: test_all_entities.44 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:heart-pulse', - 'original_name': 'Withings sleep_heart_rate_min_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_heart_rate_min_bpm', - 'unit_of_measurement': 'bpm', - }) -# --- -# name: test_all_entities.45 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_heart_rate_min_bpm henk', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_heart_rate_min_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '166.0', - }) -# --- -# name: test_all_entities.46 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_light_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_light_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.47 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_light_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_light_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '334', - }) -# --- -# name: test_all_entities.48 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep', - 'original_name': 'Withings sleep_rem_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_rem_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.49 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_rem_duration_seconds henk', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_rem_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '336', - }) -# --- # name: test_all_entities.5 StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -824,236 +486,6 @@ 'state': '2', }) # --- -# name: test_all_entities.50 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_average_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_average_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.51 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_average_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_average_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '169.0', - }) -# --- -# name: test_all_entities.52 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_max_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_max_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.53 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_max_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_max_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '170.0', - }) -# --- -# name: test_all_entities.54 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_respiratory_min_bpm henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_respiratory_min_bpm', - 'unit_of_measurement': 'br/min', - }) -# --- -# name: test_all_entities.55 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_respiratory_min_bpm henk', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_respiratory_min_bpm_henk', - 'last_changed': , - 'last_updated': , - 'state': '171.0', - }) -# --- -# name: test_all_entities.56 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_score_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:medal', - 'original_name': 'Withings sleep_score henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_score', - 'unit_of_measurement': 'points', - }) -# --- -# name: test_all_entities.57 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_score henk', - 'icon': 'mdi:medal', - 'state_class': , - 'unit_of_measurement': 'points', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_score_henk', - 'last_changed': , - 'last_updated': , - 'state': '222', - }) -# --- -# name: test_all_entities.58 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_snoring_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_snoring henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_snoring', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities.59 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_snoring henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_snoring_henk', - 'last_changed': , - 'last_updated': , - 'state': '173.0', - }) -# --- # name: test_all_entities.6 StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -1069,146 +501,6 @@ 'state': '40', }) # --- -# name: test_all_entities.60 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': 'Withings sleep_snoring_eposode_count henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_snoring_eposode_count', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities.61 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_snoring_eposode_count henk', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_snoring_eposode_count_henk', - 'last_changed': , - 'last_updated': , - 'state': '348', - }) -# --- -# name: test_all_entities.62 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_wakeup_count henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_wakeup_count', - 'unit_of_measurement': 'times', - }) -# --- -# name: test_all_entities.63 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'Withings sleep_wakeup_count henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': 'times', - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_wakeup_count_henk', - 'last_changed': , - 'last_updated': , - 'state': '350', - }) -# --- -# name: test_all_entities.64 - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'state_class': , - }), - 'config_entry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'sensor', - 'entity_category': None, - 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', - 'has_entity_name': False, - 'hidden_by': None, - 'icon': None, - 'id': , - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': 'mdi:sleep-off', - 'original_name': 'Withings sleep_wakeup_duration_seconds henk', - 'platform': 'withings', - 'supported_features': 0, - 'translation_key': None, - 'unique_id': 'withings_12345_sleep_wakeup_duration_seconds', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities.65 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'Withings sleep_wakeup_duration_seconds henk', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.withings_sleep_wakeup_duration_seconds_henk', - 'last_changed': , - 'last_updated': , - 'state': '176.0', - }) -# --- # name: test_all_entities.7 StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 96a0397257d..8351598df69 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -36,8 +36,9 @@ async def test_all_entities( ) for entity in entities: - if entity.platform == Platform.SENSOR: + if entity.domain == Platform.SENSOR: assert hass.states.get(entity.entity_id) == snapshot + assert entities async def test_update_failed( From 683046272da73341f9817f5835e3a18118ca31af Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 09:48:04 -1000 Subject: [PATCH 464/968] Switch hassio to use iter_chunks (#102031) --- homeassistant/components/hassio/http.py | 5 ++++- homeassistant/components/hassio/ingress.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 0d23a953128..cabe6f085ba 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -178,7 +178,10 @@ class HassIOView(HomeAssistantView): if should_compress(response.content_type): response.enable_compression() await response.prepare(request) - async for data in client.content.iter_chunked(8192): + # In testing iter_chunked, iter_any, and iter_chunks: + # iter_chunks was the best performing option since + # it does not have to do as much re-assembly + async for data, _ in client.content.iter_chunks(): await response.write(data) return response diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 4a612de7f87..dc4f3234b60 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -198,7 +198,10 @@ class HassIOIngress(HomeAssistantView): if should_compress(response.content_type): response.enable_compression() await response.prepare(request) - async for data in result.content.iter_chunked(8192): + # In testing iter_chunked, iter_any, and iter_chunks: + # iter_chunks was the best performing option since + # it does not have to do as much re-assembly + async for data, _ in result.content.iter_chunks(): await response.write(data) except ( From 3577547eb3c67110f3f24ba8d55ee83217e62115 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 15 Oct 2023 12:55:11 -0700 Subject: [PATCH 465/968] Skip CalDAV calendars that do not support events (#102059) --- homeassistant/components/caldav/calendar.py | 13 ++++++- tests/components/caldav/test_calendar.py | 43 ++++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index f30f79f7275..4fe5e38432a 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -114,8 +114,19 @@ def setup_platform( ) ) - # Create a default calendar if there was no custom one + # Create a default calendar if there was no custom one for all calendars + # that support events. if not config[CONF_CUSTOM_CALENDARS]: + if ( + supported_components := calendar.get_supported_components() + ) and "VEVENT" not in supported_components: + _LOGGER.debug( + "Ignoring calendar '%s' (components=%s)", + calendar.name, + supported_components, + ) + continue + name = calendar.name device_id = calendar.name entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index ddf089c10c0..f64cf699451 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -375,14 +375,16 @@ def _mocked_dav_client(*names, calendars=None): return client -def _mock_calendar(name): +def _mock_calendar(name, supported_components=None): calendar = Mock() events = [] for idx, event in enumerate(EVENTS): events.append(Event(None, "%d.ics" % idx, event, calendar, str(idx))) - + if supported_components is None: + supported_components = ["VEVENT"] calendar.search = MagicMock(return_value=events) calendar.name = name + calendar.get_supported_components = MagicMock(return_value=supported_components) return calendar @@ -1066,3 +1068,40 @@ async def test_get_events_custom_calendars( "rrule": None, } ] + + +async def test_calendar_components( + hass: HomeAssistant, +) -> None: + """Test that only calendars that support events are created.""" + calendars = [ + _mock_calendar("Calendar 1", supported_components=["VEVENT"]), + _mock_calendar("Calendar 2", supported_components=["VEVENT", "VJOURNAL"]), + _mock_calendar("Calendar 3", supported_components=["VTODO"]), + # Fallback to allow when no components are supported to be conservative + _mock_calendar("Calendar 4", supported_components=[]), + ] + with patch( + "homeassistant.components.caldav.calendar.caldav.DAVClient", + return_value=_mocked_dav_client(calendars=calendars), + ): + assert await async_setup_component( + hass, "calendar", {"calendar": CALDAV_CONFIG} + ) + await hass.async_block_till_done() + + state = hass.states.get("calendar.calendar_1") + assert state.name == "Calendar 1" + assert state.state == STATE_OFF + + state = hass.states.get("calendar.calendar_2") + assert state.name == "Calendar 2" + assert state.state == STATE_OFF + + # No entity created for To-do only component + state = hass.states.get("calendar.calendar_3") + assert not state + + state = hass.states.get("calendar.calendar_4") + assert state.name == "Calendar 4" + assert state.state == STATE_OFF From b4295e909ce96fb61581f333520771c51195f84f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sun, 15 Oct 2023 13:07:32 -0700 Subject: [PATCH 466/968] Bump screenlogicpy to v0.9.3 (#101957) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index a57ad0026e6..e61ca04374f 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.2"] + "requirements": ["screenlogicpy==0.9.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index ddc421e0744..f9fff2bcd8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2381,7 +2381,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.2 +screenlogicpy==0.9.3 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e4c4459b3e..8e2b3be4f6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1768,7 +1768,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.2 +screenlogicpy==0.9.3 # homeassistant.components.backup securetar==2023.3.0 From 24afbf3ae4c64df87128cc7dfc62241583cc89a1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 15 Oct 2023 23:03:45 +0200 Subject: [PATCH 467/968] Address late Withings review (#102075) --- homeassistant/components/withings/diagnostics.py | 2 +- homeassistant/components/withings/sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index 2424452d0f5..efa0421f205 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -40,6 +40,6 @@ async def async_get_config_entry_diagnostics( "has_valid_external_webhook_url": has_valid_external_webhook_url, "has_cloudhooks": has_cloudhooks, "webhooks_connected": measurement_coordinator.webhooks_connected, - "received_measurements": list(measurement_coordinator.data.keys()), + "received_measurements": list(measurement_coordinator.data), "received_sleep_data": sleep_coordinator.data is not None, } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index e5096701b48..d09ae550d0f 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -406,7 +406,7 @@ async def async_setup_entry( DOMAIN ][entry.entry_id][MEASUREMENT_COORDINATOR] - current_measurement_types = set(measurement_coordinator.data.keys()) + current_measurement_types = set(measurement_coordinator.data) entities: list[SensorEntity] = [] entities.extend( @@ -419,7 +419,7 @@ async def async_setup_entry( def _async_measurement_listener() -> None: """Listen for new measurements and add sensors if they did not exist.""" - received_measurement_types = set(measurement_coordinator.data.keys()) + received_measurement_types = set(measurement_coordinator.data) new_measurement_types = received_measurement_types - current_measurement_types if new_measurement_types: current_measurement_types.update(new_measurement_types) From 3c3f512583b32de771c76f0c2433ff3e417f50a6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 15 Oct 2023 23:12:41 +0200 Subject: [PATCH 468/968] Remove setup_platform for demo (#100867) --- homeassistant/components/demo/__init__.py | 2 +- homeassistant/components/demo/air_quality.py | 17 +++--------- .../components/demo/alarm_control_panel.py | 17 +++--------- homeassistant/components/demo/fan.py | 17 +++--------- homeassistant/components/demo/humidifier.py | 17 +++--------- .../components/demo/image_processing.py | 6 ++--- homeassistant/components/demo/lock.py | 27 ++++++------------- homeassistant/components/demo/media_player.py | 17 +++--------- homeassistant/components/demo/remote.py | 13 +-------- homeassistant/components/demo/siren.py | 17 +++--------- homeassistant/components/demo/vacuum.py | 11 -------- homeassistant/components/demo/water_heater.py | 17 +++--------- homeassistant/components/demo/weather.py | 13 +-------- tests/components/demo/test_fan.py | 15 ++++++++++- tests/components/demo/test_humidifier.py | 15 ++++++++++- tests/components/demo/test_lock.py | 14 ++++++++-- tests/components/demo/test_media_player.py | 6 +++++ tests/components/demo/test_remote.py | 15 ++++++++++- tests/components/demo/test_siren.py | 13 ++++++++- tests/components/demo/test_vacuum.py | 14 +++++++++- tests/components/demo/test_water_heater.py | 15 ++++++++++- tests/components/demo/test_weather.py | 17 +++++++++--- .../manual/test_alarm_control_panel.py | 2 +- 23 files changed, 149 insertions(+), 168 deletions(-) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b40e1ede232..98226d68030 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -48,6 +48,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, + Platform.WEATHER, ] COMPONENTS_WITH_DEMO_PLATFORM = [ @@ -56,7 +57,6 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.NOTIFY, Platform.IMAGE_PROCESSING, Platform.DEVICE_TRACKER, - Platform.WEATHER, ] CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index c63729f2cd6..d1a56112497 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -5,19 +5,6 @@ from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Air Quality.""" - async_add_entities( - [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] - ) async def async_setup_entry( @@ -26,7 +13,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + async_add_entities( + [DemoAirQuality("Home", 14, 23, 100), DemoAirQuality("Office", 4, 16, None)] + ) class DemoAirQuality(AirQualityEntity): diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py index 3a94aaa7c29..1c15e9d5b7e 100644 --- a/homeassistant/components/demo/alarm_control_panel.py +++ b/homeassistant/components/demo/alarm_control_panel.py @@ -19,16 +19,14 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo alarm control panel platform.""" + """Set up the Demo config entry.""" async_add_entities( [ ManualAlarm( # type:ignore[no-untyped-call] @@ -75,12 +73,3 @@ async def async_setup_platform( ) ] ) - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 5c8cc849285..211389a5466 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -7,7 +7,6 @@ from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType PRESET_MODE_AUTO = "auto" PRESET_MODE_SMART = "smart" @@ -20,13 +19,12 @@ FULL_SUPPORT = ( LIMITED_SUPPORT = FanEntityFeature.SET_SPEED -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the demo fan platform.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoPercentageFan( @@ -88,15 +86,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class BaseDemoFan(FanEntity): """A demonstration fan component that uses legacy fan speeds.""" diff --git a/homeassistant/components/demo/humidifier.py b/homeassistant/components/demo/humidifier.py index 2e16a04e171..a63e3e1983f 100644 --- a/homeassistant/components/demo/humidifier.py +++ b/homeassistant/components/demo/humidifier.py @@ -12,18 +12,16 @@ from homeassistant.components.humidifier import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_FLAGS = HumidifierEntityFeature(0) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo humidifier devices.""" + """Set up the Demo humidifier devices config entry.""" async_add_entities( [ DemoHumidifier( @@ -52,15 +50,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo humidifier devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoHumidifier(HumidifierEntity): """Representation of a demo humidifier device.""" diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py index 21322f49718..71ea9d97bf6 100644 --- a/homeassistant/components/demo/image_processing.py +++ b/homeassistant/components/demo/image_processing.py @@ -10,14 +10,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the demo image processing platform.""" - add_entities( + async_add_entities( [ DemoImageProcessingFace("camera.demo_camera", "Demo Face"), ] diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index e75c2074aab..3a6780ce30e 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -15,35 +15,24 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType LOCK_UNLOCK_DELAY = 2 # Used to give a realistic lock/unlock experience in frontend -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo lock platform.""" - async_add_entities( - [ - DemoLock("Front Door", STATE_LOCKED), - DemoLock("Kitchen Door", STATE_UNLOCKED), - DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), - DemoLock("Openable Lock", STATE_LOCKED, True), - ] - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) + async_add_entities( + [ + DemoLock("Front Door", STATE_LOCKED), + DemoLock("Kitchen Door", STATE_UNLOCKED), + DemoLock("Poorly Installed Door", STATE_UNLOCKED, False, True), + DemoLock("Openable Lock", STATE_LOCKED, True), + ] + ) class DemoLock(LockEntity): diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 9d335c34cdb..35bd35a2245 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -15,17 +15,15 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the media player demo platform.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoYoutubePlayer( @@ -44,15 +42,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - SOUND_MODE_LIST = ["Music", "Movie"] DEFAULT_SOUND_MODE = "Music" diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py index f2c1ce11b0a..40df72b073b 100644 --- a/homeassistant/components/demo/remote.py +++ b/homeassistant/components/demo/remote.py @@ -9,7 +9,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType async def async_setup_entry( @@ -18,17 +17,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - setup_platform(hass, {}, async_add_entities) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities_callback: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the demo remotes.""" - add_entities_callback( + async_add_entities( [ DemoRemote("Remote One", False, None), DemoRemote("Remote Two", True, "mdi:remote"), diff --git a/homeassistant/components/demo/siren.py b/homeassistant/components/demo/siren.py index 0720114861c..3b3c3dfc610 100644 --- a/homeassistant/components/demo/siren.py +++ b/homeassistant/components/demo/siren.py @@ -7,18 +7,16 @@ from homeassistant.components.siren import SirenEntity, SirenEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_FLAGS = SirenEntityFeature.TURN_OFF | SirenEntityFeature.TURN_ON -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo siren devices.""" + """Set up the Demo siren devices config entry.""" async_add_entities( [ DemoSiren(name="Siren"), @@ -32,15 +30,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo siren devices config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoSiren(SirenEntity): """Representation of a demo siren device.""" diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index 11b69272775..83216ebdba6 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import event from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_MINIMAL_SERVICES = VacuumEntityFeature.TURN_ON | VacuumEntityFeature.TURN_OFF @@ -79,16 +78,6 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo vacuums.""" async_add_entities( [ DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py index 0ab175691f8..beb46c5d8ad 100644 --- a/homeassistant/components/demo/water_heater.py +++ b/homeassistant/components/demo/water_heater.py @@ -11,7 +11,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType SUPPORT_FLAGS_HEATER = ( WaterHeaterEntityFeature.TARGET_TEMPERATURE @@ -21,13 +20,12 @@ SUPPORT_FLAGS_HEATER = ( ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Demo water_heater devices.""" + """Set up the Demo config entry.""" async_add_entities( [ DemoWaterHeater( @@ -40,15 +38,6 @@ async def async_setup_platform( ) -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Demo config entry.""" - await async_setup_platform(hass, {}, async_add_entities) - - class DemoWaterHeater(WaterHeaterEntity): """Representation of a demo water_heater device.""" diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 758b5075041..a990e26c658 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -27,7 +27,6 @@ from homeassistant.const import UnitOfPressure, UnitOfSpeed, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util CONDITION_CLASSES: dict[str, list[str]] = { @@ -61,17 +60,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Demo config entry.""" - setup_platform(hass, {}, async_add_entities) - - -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Demo weather.""" - add_entities( + async_add_entities( [ DemoWeather( "South", diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py index 9c1b84313f6..58a8c99ea3c 100644 --- a/tests/components/demo/test_fan.py +++ b/tests/components/demo/test_fan.py @@ -1,4 +1,6 @@ """Test cases around the demo fan platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import fan @@ -15,6 +17,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,8 +34,18 @@ FANS_WITH_PRESET_MODES = FULL_FAN_ENTITY_IDS + [ PERCENTAGE_MODEL_FANS = ["fan.percentage_full_fan", "fan.percentage_limited_fan"] +@pytest.fixture +async def fan_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.FAN], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, fan_only: None): """Initialize components.""" assert await async_setup_component(hass, fan.DOMAIN, {"fan": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_humidifier.py b/tests/components/demo/test_humidifier.py index 13aeb5724b9..97647f0a90f 100644 --- a/tests/components/demo/test_humidifier.py +++ b/tests/components/demo/test_humidifier.py @@ -1,5 +1,7 @@ """The tests for the demo humidifier component.""" +from unittest.mock import patch + import pytest import voluptuous as vol @@ -22,6 +24,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -31,8 +34,18 @@ ENTITY_HYGROSTAT = "humidifier.hygrostat" ENTITY_HUMIDIFIER = "humidifier.humidifier" +@pytest.fixture +async def humidifier_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.HUMIDIFIER], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_humidifier(hass, disable_platforms): +async def setup_demo_humidifier(hass: HomeAssistant, humidifier_only: None): """Initialize setup demo humidifier.""" assert await async_setup_component( hass, DOMAIN, {"humidifier": {"platform": "demo"}} diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py index 377f9f2d765..f72f5b01c19 100644 --- a/tests/components/demo/test_lock.py +++ b/tests/components/demo/test_lock.py @@ -15,7 +15,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED +from homeassistant.const import ATTR_ENTITY_ID, EVENT_STATE_CHANGED, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,8 +27,18 @@ POORLY_INSTALLED = "lock.poorly_installed_door" OPENABLE_LOCK = "lock.openable_lock" +@pytest.fixture +async def lock_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.LOCK], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, lock_only: None): """Set up demo component.""" assert await async_setup_component( hass, LOCK_DOMAIN, {LOCK_DOMAIN: {"platform": DOMAIN}} diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 1681cdb9101..ff6274af1b5 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -13,6 +13,7 @@ from homeassistant.const import ( STATE_OFF, STATE_PAUSED, STATE_PLAYING, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION @@ -26,6 +27,11 @@ TEST_ENTITY_ID = "media_player.walkman" @pytest.fixture(autouse=True) def autouse_disable_platforms(disable_platforms): """Auto use the disable_platforms fixture.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.MEDIA_PLAYER], + ): + yield @pytest.fixture(name="mock_media_seek") diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py index d1e5b4007ca..5fafffae372 100644 --- a/tests/components/demo/test_remote.py +++ b/tests/components/demo/test_remote.py @@ -1,4 +1,6 @@ """The tests for the demo remote component.""" +from unittest.mock import patch + import pytest import homeassistant.components.remote as remote @@ -9,6 +11,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -17,8 +20,18 @@ ENTITY_ID = "remote.remote_one" SERVICE_SEND_COMMAND = "send_command" +@pytest.fixture +async def remote_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.REMOTE], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_component(hass, disable_platforms): +async def setup_component(hass: HomeAssistant, remote_only: None): """Initialize components.""" assert await async_setup_component( hass, remote.DOMAIN, {"remote": {"platform": "demo"}} diff --git a/tests/components/demo/test_siren.py b/tests/components/demo/test_siren.py index 8051aaf5b20..1434248599c 100644 --- a/tests/components/demo/test_siren.py +++ b/tests/components/demo/test_siren.py @@ -16,6 +16,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -24,8 +25,18 @@ ENTITY_SIREN = "siren.siren" ENTITY_SIREN_WITH_ALL_FEATURES = "siren.siren_with_all_features" +@pytest.fixture +async def siren_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.SIREN], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_siren(hass, disable_platforms): +async def setup_demo_siren(hass: HomeAssistant, siren_only: None): """Initialize setup demo siren.""" assert await async_setup_component(hass, DOMAIN, {"siren": {"platform": "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 711d0217f2d..cc0fcfeb2d2 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -1,5 +1,6 @@ """The tests for the Demo vacuum platform.""" from datetime import timedelta +from unittest.mock import patch import pytest @@ -35,6 +36,7 @@ from homeassistant.const import ( CONF_PLATFORM, STATE_OFF, STATE_ON, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -52,8 +54,18 @@ ENTITY_VACUUM_NONE = f"{DOMAIN}.{DEMO_VACUUM_NONE}".lower() ENTITY_VACUUM_STATE = f"{DOMAIN}.{DEMO_VACUUM_STATE}".lower() +@pytest.fixture +async def vacuum_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.VACUUM], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_demo_vacuum(hass, disable_platforms): +async def setup_demo_vacuum(hass: HomeAssistant, vacuum_only: None): """Initialize setup demo vacuum.""" assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "demo"}}) await hass.async_block_till_done() diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py index cc91f57d872..6b133297e34 100644 --- a/tests/components/demo/test_water_heater.py +++ b/tests/components/demo/test_water_heater.py @@ -1,8 +1,11 @@ """The tests for the demo water_heater component.""" +from unittest.mock import patch + import pytest import voluptuous as vol from homeassistant.components import water_heater +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -13,8 +16,18 @@ ENTITY_WATER_HEATER = "water_heater.demo_water_heater" ENTITY_WATER_HEATER_CELSIUS = "water_heater.demo_water_heater_celsius" +@pytest.fixture +async def water_heater_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.WATER_HEATER], + ): + yield + + @pytest.fixture(autouse=True) -async def setup_comp(hass, disable_platforms): +async def setup_comp(hass: HomeAssistant, water_heater_only: None): """Set up demo component.""" hass.config.units = US_CUSTOMARY_SYSTEM assert await async_setup_component( diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index ced801a4d46..bb91535192c 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -1,6 +1,7 @@ """The tests for the demo weather component.""" import datetime from typing import Any +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -15,7 +16,7 @@ from homeassistant.components.weather import ( ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, ) -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM @@ -23,7 +24,17 @@ from homeassistant.util.unit_system import METRIC_SYSTEM from tests.typing import WebSocketGenerator -async def test_attributes(hass: HomeAssistant, disable_platforms) -> None: +@pytest.fixture +async def weather_only() -> None: + """Enable only the datetime platform.""" + with patch( + "homeassistant.components.demo.COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM", + [Platform.WEATHER], + ): + yield + + +async def test_attributes(hass: HomeAssistant, weather_only) -> None: """Test weather attributes.""" assert await async_setup_component( hass, weather.DOMAIN, {"weather": {"platform": "demo"}} @@ -115,7 +126,7 @@ async def test_forecast( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, - disable_platforms: None, + weather_only: None, forecast_type: str, expected_forecast: list[dict[str, Any]], ) -> None: diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py index f1a4b2da2ef..9ca8ac9e2ba 100644 --- a/tests/components/manual/test_alarm_control_panel.py +++ b/tests/components/manual/test_alarm_control_panel.py @@ -40,7 +40,7 @@ async def test_setup_demo_platform(hass: HomeAssistant) -> None: """Test setup.""" mock = MagicMock() add_entities = mock.MagicMock() - await demo.async_setup_platform(hass, {}, add_entities) + await demo.async_setup_entry(hass, {}, add_entities) assert add_entities.call_count == 1 From 93f10cdce82f1885d62b934af9c542adfde6e5ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 11:14:19 -1000 Subject: [PATCH 469/968] Move event permissions out of the websocket api into auth (#101975) --- .../permissions/events.py} | 21 ++++++------------- homeassistant/components/frontend/__init__.py | 8 +++++-- homeassistant/components/lovelace/const.py | 9 ++++++-- .../persistent_notification/__init__.py | 4 ---- homeassistant/components/recorder/__init__.py | 9 +++++--- homeassistant/components/recorder/const.py | 11 ++++++---- .../components/shopping_list/const.py | 5 ++++- .../components/websocket_api/commands.py | 5 +---- homeassistant/const.py | 7 +++++++ 9 files changed, 44 insertions(+), 35 deletions(-) rename homeassistant/{components/websocket_api/permissions.py => auth/permissions/events.py} (70%) diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/auth/permissions/events.py similarity index 70% rename from homeassistant/components/websocket_api/permissions.py rename to homeassistant/auth/permissions/events.py index f3a0cebe51f..d50da96a39f 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/auth/permissions/events.py @@ -1,26 +1,18 @@ -"""Permission constants for the websocket API. - -Separate file to avoid circular imports. -""" +"""Permission for events.""" from __future__ import annotations from typing import Final -from homeassistant.components.frontend import EVENT_PANELS_UPDATED -from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED -from homeassistant.components.persistent_notification import ( - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, -) -from homeassistant.components.recorder import ( - EVENT_RECORDER_5MIN_STATISTICS_GENERATED, - EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, -) -from homeassistant.components.shopping_list import EVENT_SHOPPING_LIST_UPDATED from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_CORE_CONFIG_UPDATE, + EVENT_LOVELACE_UPDATED, + EVENT_PANELS_UPDATED, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, + EVENT_SHOPPING_LIST_UPDATED, EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, ) @@ -38,7 +30,6 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = { EVENT_ENTITY_REGISTRY_UPDATED, EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, EVENT_SERVICE_REGISTERED, diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a5a4d76f9e7..98b6f0331b5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -17,7 +17,12 @@ from homeassistant.components import onboarding, websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.config import async_hass_config_yaml -from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED +from homeassistant.const import ( + CONF_MODE, + CONF_NAME, + EVENT_PANELS_UPDATED, + EVENT_THEMES_UPDATED, +) from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv @@ -40,7 +45,6 @@ CONF_EXTRA_MODULE_URL = "extra_module_url" CONF_EXTRA_JS_URL_ES5 = "extra_js_url_es5" CONF_FRONTEND_REPO = "development_repo" CONF_JS_VERSION = "javascript_version" -EVENT_PANELS_UPDATED = "panels_updated" DEFAULT_THEME_COLOR = "#03A9F4" diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 6952a80a214..01110bb8a7c 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -3,13 +3,18 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_ICON, CONF_MODE, CONF_TYPE, CONF_URL +from homeassistant.const import ( + CONF_ICON, + CONF_MODE, + CONF_TYPE, + CONF_URL, + EVENT_LOVELACE_UPDATED, # noqa: F401 +) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util import slugify DOMAIN = "lovelace" -EVENT_LOVELACE_UPDATED = "lovelace_updated" DEFAULT_ICON = "hass:view-dashboard" diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index c9e8e3703db..9ecb91bdb7f 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -30,10 +30,6 @@ ATTR_TITLE: Final = "title" ATTR_STATUS: Final = "status" -# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 -EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" - - class Notification(TypedDict): """Persistent notification.""" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 1c00149192f..c82d431a8fa 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -6,7 +6,12 @@ from typing import Any import voluptuous as vol -from homeassistant.const import CONF_EXCLUDE, EVENT_STATE_CHANGED +from homeassistant.const import ( + CONF_EXCLUDE, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401 + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 + EVENT_STATE_CHANGED, +) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import ( @@ -25,8 +30,6 @@ from .const import ( # noqa: F401 CONF_DB_INTEGRITY_CHECK, DATA_INSTANCE, DOMAIN, - EVENT_RECORDER_5MIN_STATISTICS_GENERATED, - EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, INTEGRATION_PLATFORM_COMPILE_STATISTICS, INTEGRATION_PLATFORMS_LOAD_IN_RECORDER_THREAD, SQLITE_URL_PREFIX, diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index dbfa1a2ff73..66d46c0c20e 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -2,7 +2,13 @@ from enum import StrEnum -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_RESTORED, + ATTR_SUPPORTED_FEATURES, + EVENT_RECORDER_5MIN_STATISTICS_GENERATED, # noqa: F401 + EVENT_RECORDER_HOURLY_STATISTICS_GENERATED, # noqa: F401 +) from homeassistant.helpers.json import JSON_DUMP # noqa: F401 DATA_INSTANCE = "recorder_instance" @@ -13,9 +19,6 @@ MYSQLDB_URL_PREFIX = "mysql://" MYSQLDB_PYMYSQL_URL_PREFIX = "mysql+pymysql://" DOMAIN = "recorder" -EVENT_RECORDER_5MIN_STATISTICS_GENERATED = "recorder_5min_statistics_generated" -EVENT_RECORDER_HOURLY_STATISTICS_GENERATED = "recorder_hourly_statistics_generated" - CONF_DB_INTEGRITY_CHECK = "db_integrity_check" MAX_QUEUE_BACKLOG_MIN_VALUE = 65000 diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py index c519123a414..22553d9c316 100644 --- a/homeassistant/components/shopping_list/const.py +++ b/homeassistant/components/shopping_list/const.py @@ -1,6 +1,9 @@ """All constants related to the shopping list component.""" + +from homeassistant.const import EVENT_SHOPPING_LIST_UPDATED # noqa: F401 + DOMAIN = "shopping_list" -EVENT_SHOPPING_LIST_UPDATED = "shopping_list_updated" + ATTR_REVERSE = "reverse" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index cef9e7bb706..a29bee86116 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -12,6 +12,7 @@ import voluptuous as vol from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.auth.permissions.events import SUBSCRIBE_ALLOWLIST from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, @@ -128,10 +129,6 @@ def handle_subscribe_events( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle subscribe events command.""" - # Circular dep - # pylint: disable-next=import-outside-toplevel - from .permissions import SUBSCRIBE_ALLOWLIST - event_type = msg["event_type"] if event_type not in SUBSCRIBE_ALLOWLIST and not connection.user.is_admin: diff --git a/homeassistant/const.py b/homeassistant/const.py index 31256e502a4..2d84a57afa4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -293,6 +293,13 @@ EVENT_SERVICE_REGISTERED: Final = "service_registered" EVENT_SERVICE_REMOVED: Final = "service_removed" EVENT_STATE_CHANGED: Final = "state_changed" EVENT_THEMES_UPDATED: Final = "themes_updated" +EVENT_PANELS_UPDATED: Final = "panels_updated" +EVENT_LOVELACE_UPDATED: Final = "lovelace_updated" +EVENT_RECORDER_5MIN_STATISTICS_GENERATED: Final = "recorder_5min_statistics_generated" +EVENT_RECORDER_HOURLY_STATISTICS_GENERATED: Final = ( + "recorder_hourly_statistics_generated" +) +EVENT_SHOPPING_LIST_UPDATED: Final = "shopping_list_updated" # #### DEVICE CLASSES #### # DEVICE_CLASS_* below are deprecated as of 2021.12 From 11740d1e68da2e4defb02cad33851cf9abedd3d6 Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Sun, 15 Oct 2023 23:26:09 +0200 Subject: [PATCH 470/968] Remove shorthand unique id in AsusWrt ScannerEntity (#102076) Remove _attr_unique_id in AsusWrt ScannerEntity --- homeassistant/components/asuswrt/device_tracker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 3a9abdd7e85..5d923abf744 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -58,7 +58,6 @@ class AsusWrtDevice(ScannerEntity): """Initialize a AsusWrt device.""" self._router = router self._device = device - self._attr_unique_id = device.mac self._attr_name = device.name or DEFAULT_DEVICE_NAME @property From 36e1c740fd1f37cb2e3313444a7d11bca8703dcc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 11:38:20 -1000 Subject: [PATCH 471/968] Fix ingress sending an empty body for GET requests (#101917) --- homeassistant/components/hassio/http.py | 2 +- homeassistant/components/hassio/ingress.py | 2 +- tests/components/hassio/test_http.py | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index cabe6f085ba..419d80484cf 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -164,7 +164,7 @@ class HassIOView(HomeAssistantView): method=request.method, url=f"http://{self._host}/{quote(path)}", params=request.query, - data=request.content, + data=request.content if request.method != "GET" else None, headers=headers, timeout=_get_timeout(path), ) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index dc4f3234b60..b8c5873b967 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -162,7 +162,7 @@ class HassIOIngress(HomeAssistantView): headers=source_header, params=request.query, allow_redirects=False, - data=request.content, + data=request.content if request.method != "GET" else None, timeout=ClientTimeout(total=None), skip_auto_headers={hdrs.CONTENT_TYPE}, ) as result: diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index e659fbe4b8f..2e14c21f20a 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -450,11 +450,26 @@ async def test_backup_download_headers( async def test_stream(hassio_client, aioclient_mock: AiohttpClientMocker) -> None: """Verify that the request is a stream.""" - aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") - await hassio_client.get("/api/hassio/app/entrypoint.js", data="test") + content_type = "multipart/form-data; boundary='--webkit'" + aioclient_mock.post("http://127.0.0.1/backups/new/upload") + resp = await hassio_client.post( + "/api/hassio/backups/new/upload", headers={"Content-Type": content_type} + ) + # Check we got right response + assert resp.status == HTTPStatus.OK assert isinstance(aioclient_mock.mock_calls[-1][2], StreamReader) +async def test_simple_get_no_stream( + hassio_client, aioclient_mock: AiohttpClientMocker +) -> None: + """Verify that a simple GET request is not a stream.""" + aioclient_mock.get("http://127.0.0.1/app/entrypoint.js") + resp = await hassio_client.get("/api/hassio/app/entrypoint.js") + assert resp.status == HTTPStatus.OK + assert aioclient_mock.mock_calls[-1][2] is None + + async def test_entrypoint_cache_control( hassio_client, aioclient_mock: AiohttpClientMocker ) -> None: From 653da6e31f815af2df376ae6d03cab2793376145 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 11:39:09 -1000 Subject: [PATCH 472/968] Reduce websocket event and state JSON construction overhead (#101974) --- .../components/websocket_api/messages.py | 70 ++++++++++++------- .../components/websocket_api/test_messages.py | 2 +- 2 files changed, 44 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 6e88c36c328..e1b038f4222 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -32,9 +32,6 @@ MINIMAL_MESSAGE_SCHEMA: Final = vol.Schema( # Base schema to extend by message handlers BASE_COMMAND_MESSAGE_SCHEMA: Final = vol.Schema({vol.Required("id"): cv.positive_int}) -IDEN_TEMPLATE: Final = "__IDEN__" -IDEN_JSON_TEMPLATE: Final = '"__IDEN__"' - STATE_DIFF_ADDITIONS = "+" STATE_DIFF_REMOVALS = "-" @@ -42,6 +39,21 @@ ENTITY_EVENT_ADD = "a" ENTITY_EVENT_REMOVE = "r" ENTITY_EVENT_CHANGE = "c" +BASE_ERROR_MESSAGE = { + "type": const.TYPE_RESULT, + "success": False, +} + +INVALID_JSON_PARTIAL_MESSAGE = JSON_DUMP( + { + **BASE_ERROR_MESSAGE, + "error": { + "code": const.ERR_UNKNOWN_ERROR, + "message": "Invalid JSON in response", + }, + } +) + def result_message(iden: int, result: Any = None) -> dict[str, Any]: """Return a success result message.""" @@ -50,24 +62,21 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: def construct_result_message(iden: int, payload: str) -> str: """Construct a success result message JSON.""" - iden_str = str(iden) - return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}' + return f'{{"id":{iden},"type":"result","success":true,"result":{payload}}}' def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: """Return an error result message.""" return { "id": iden, - "type": const.TYPE_RESULT, - "success": False, + **BASE_ERROR_MESSAGE, "error": {"code": code, "message": message}, } def construct_event_message(iden: int, payload: str) -> str: """Construct an event message JSON.""" - iden_str = str(iden) - return f'{{"id":{iden_str},"type":"event","event":{payload}}}' + return f'{{"id":{iden},"type":"event","event":{payload}}}' def event_message(iden: int, event: Any) -> dict[str, Any]: @@ -84,18 +93,19 @@ def cached_event_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return _cached_event_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1) + return f'{_partial_cached_event_message(event)[:-1]},"id":{iden}}}' @lru_cache(maxsize=128) -def _cached_event_message(event: Event) -> str: +def _partial_cached_event_message(event: Event) -> str: """Cache and serialize the event to json. - The IDEN_TEMPLATE is used which will be replaced - with the actual iden in cached_event_message + The message is constructed without the id which appended + in cached_event_message. """ - return message_to_json( - {"id": IDEN_TEMPLATE, "type": "event", "event": event.as_dict()} + return ( + _message_to_json_or_none({"type": "event", "event": event.as_dict()}) + or INVALID_JSON_PARTIAL_MESSAGE ) @@ -108,18 +118,19 @@ def cached_state_diff_message(iden: int, event: Event) -> str: all getting many of the same events (mostly state changed) we can avoid serializing the same data for each connection. """ - return _cached_state_diff_message(event).replace(IDEN_JSON_TEMPLATE, str(iden), 1) + return f'{_partial_cached_state_diff_message(event)[:-1]},"id":{iden}}}' @lru_cache(maxsize=128) -def _cached_state_diff_message(event: Event) -> str: +def _partial_cached_state_diff_message(event: Event) -> str: """Cache and serialize the event to json. - The IDEN_TEMPLATE is used which will be replaced - with the actual iden in cached_event_message + The message is constructed without the id which + will be appended in cached_state_diff_message """ - return message_to_json( - {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)} + return ( + _message_to_json_or_none({"type": "event", "event": _state_diff_event(event)}) + or INVALID_JSON_PARTIAL_MESSAGE ) @@ -189,8 +200,8 @@ def _state_diff( return {ENTITY_EVENT_CHANGE: {new_state.entity_id: diff}} -def message_to_json(message: dict[str, Any]) -> str: - """Serialize a websocket message to json.""" +def _message_to_json_or_none(message: dict[str, Any]) -> str | None: + """Serialize a websocket message to json or return None.""" try: return JSON_DUMP(message) except (ValueError, TypeError): @@ -200,8 +211,13 @@ def message_to_json(message: dict[str, Any]) -> str: find_paths_unserializable_data(message, dump=JSON_DUMP) ), ) - return JSON_DUMP( - error_message( - message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" - ) + return None + + +def message_to_json(message: dict[str, Any]) -> str: + """Serialize a websocket message to json or return an error.""" + return _message_to_json_or_none(message) or JSON_DUMP( + error_message( + message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) + ) diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 6aafb9f2685..35ed55183d4 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -2,7 +2,7 @@ import pytest from homeassistant.components.websocket_api.messages import ( - _cached_event_message as lru_event_cache, + _partial_cached_event_message as lru_event_cache, _state_diff_event, cached_event_message, message_to_json, From 8dd8af071859ed330a71316073e51ec3c00931a9 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Sun, 15 Oct 2023 16:39:41 -0500 Subject: [PATCH 473/968] Fix google_maps same last_seen bug (#101971) --- homeassistant/components/google_maps/device_tracker.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index be776df1751..93810d0f21d 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -118,9 +118,7 @@ class GoogleMapsScanner: ) _LOGGER.debug("%s < %s", last_seen, self._prev_seen[dev_id]) continue - if last_seen == self._prev_seen.get(dev_id, last_seen) and hasattr( - self, "success_init" - ): + if last_seen == self._prev_seen.get(dev_id): _LOGGER.debug( "Ignoring %s update because timestamp " "is the same as the last timestamp %s", From d0fb9941999432f65e79a76b53aa2ad445a1e62a Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Sun, 15 Oct 2023 23:40:06 +0200 Subject: [PATCH 474/968] Set Mac as connection to link HomeWizard devices on network (#101944) --- homeassistant/components/homewizard/entity.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 3279c9ba41b..51dbe9fcad3 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -1,8 +1,8 @@ """Base entity for the HomeWizard integration.""" from __future__ import annotations -from homeassistant.const import ATTR_IDENTIFIERS -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.const import ATTR_CONNECTIONS, ATTR_IDENTIFIERS +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN @@ -25,6 +25,10 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): ) if coordinator.data.device.serial is not None: + self._attr_device_info[ATTR_CONNECTIONS] = { + (CONNECTION_NETWORK_MAC, coordinator.data.device.serial) + } + self._attr_device_info[ATTR_IDENTIFIERS] = { (DOMAIN, coordinator.data.device.serial) } From e5ebdf7ad42b6762f145c364c010264aa7bc73f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 11:40:43 -1000 Subject: [PATCH 475/968] Remove implict name check from Entity base class (#101905) --- homeassistant/helpers/entity.py | 35 +---------- tests/helpers/test_entity.py | 106 +++++++++++--------------------- 2 files changed, 39 insertions(+), 102 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 4bb3e5ef5bd..1bc8f0b308b 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -268,9 +268,6 @@ class Entity(ABC): # it should be using async_write_ha_state. _async_update_ha_state_reported = False - # If we reported this entity is implicitly using device name - _implicit_device_name_reported = False - # If we reported this entity was added without its platform set _no_platform_reported = False @@ -358,22 +355,6 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id - def _report_implicit_device_name(self) -> None: - """Report entities which use implicit device name.""" - if self._implicit_device_name_reported: - return - report_issue = self._suggest_report_issue() - _LOGGER.warning( - ( - "Entity %s (%s) is implicitly using device name by not setting its " - "name. Instead, the name should be set to None, please %s" - ), - self.entity_id, - type(self), - report_issue, - ) - self._implicit_device_name_reported = True - @property def use_device_name(self) -> bool: """Return if this entity does not have its own name. @@ -388,20 +369,8 @@ class Entity(ABC): return False if hasattr(self, "entity_description"): - if not (name := self.entity_description.name): - return True - if name is UNDEFINED and not self._default_to_device_class_name(): - # Backwards compatibility with leaving EntityDescription.name unassigned - # for device name. - # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - self._report_implicit_device_name() - return True - return False - if self.name is UNDEFINED and not self._default_to_device_class_name(): - # Backwards compatibility with not overriding name property for device name. - # Deprecated in HA Core 2023.6, remove in HA Core 2023.9 - self._report_implicit_device_name() - return True + return not self.entity_description.name + return not self.name @property diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 572a2afaa5d..cf76083fe7a 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -956,17 +956,11 @@ async def test_entity_description_fallback() -> None: async def _test_friendly_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, ent: entity.Entity, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name.""" - expected_warning = ( - f"Entity {ent.entity_id} ({type(ent)}) is implicitly using device name" - ) - async def async_setup_entry(hass, config_entry, async_add_entities): """Mock setup entry method.""" async_add_entities([ent]) @@ -985,7 +979,6 @@ async def _test_friendly_name( assert len(hass.states.async_entity_ids()) == 1 state = hass.states.async_all()[0] assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name - assert (expected_warning in caplog.text) is warn_implicit_name await async_update_entity(hass, ent.entity_id) assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name @@ -997,25 +990,22 @@ async def _test_friendly_name( "entity_name", "device_name", "expected_friendly_name", - "warn_implicit_name", ), ( - (False, "Entity Blu", "Device Bla", "Entity Blu", False), - (False, None, "Device Bla", None, False), - (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu", False), - (True, None, "Device Bla", "Device Bla", False), - (True, "Entity Blu", UNDEFINED, "Entity Blu", False), - (True, "Entity Blu", None, "Mock Title Entity Blu", False), + (False, "Entity Blu", "Device Bla", "Entity Blu"), + (False, None, "Device Bla", None), + (True, "Entity Blu", "Device Bla", "Device Bla Entity Blu"), + (True, None, "Device Bla", "Device Bla"), + (True, "Entity Blu", UNDEFINED, "Entity Blu"), + (True, "Entity Blu", None, "Mock Title Entity Blu"), ), ) async def test_friendly_name_attr( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, device_name: str | None | UndefinedType, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity uses _attr_*.""" @@ -1031,31 +1021,27 @@ async def test_friendly_name_attr( ent._attr_name = entity_name await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla", True), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_description( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has an entity description.""" @@ -1072,31 +1058,27 @@ async def test_friendly_name_description( ) await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla English cls", False), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla English cls"), ), ) async def test_friendly_name_description_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has an entity description.""" @@ -1139,31 +1121,27 @@ async def test_friendly_name_description_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), - (True, UNDEFINED, "Device Bla", True), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_property( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has overridden the name property.""" @@ -1179,32 +1157,28 @@ async def test_friendly_name_property( ) await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "entity_name", "expected_friendly_name"), ( - (False, "Entity Blu", "Entity Blu", False), - (False, None, None, False), - (False, UNDEFINED, None, False), - (True, "Entity Blu", "Device Bla Entity Blu", False), - (True, None, "Device Bla", False), + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (False, UNDEFINED, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), # Won't use the device class name because the entity overrides the name property - (True, UNDEFINED, "Device Bla None", False), + (True, UNDEFINED, "Device Bla None"), ), ) async def test_friendly_name_property_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, entity_name: str | None, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has overridden the name property.""" @@ -1244,26 +1218,22 @@ async def test_friendly_name_property_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) @pytest.mark.parametrize( - ("has_entity_name", "expected_friendly_name", "warn_implicit_name"), + ("has_entity_name", "expected_friendly_name"), ( - (False, None, False), - (True, "Device Bla English cls", False), + (False, None), + (True, "Device Bla English cls"), ), ) async def test_friendly_name_device_class_name( hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, has_entity_name: bool, expected_friendly_name: str | None, - warn_implicit_name: bool, ) -> None: """Test friendly name when the entity has not set the name in any way.""" @@ -1302,10 +1272,8 @@ async def test_friendly_name_device_class_name( ): await _test_friendly_name( hass, - caplog, ent, expected_friendly_name, - warn_implicit_name, ) From 422252e3a08fd76be0ea0791a8e096012ba886f9 Mon Sep 17 00:00:00 2001 From: Robert Van Gorkom Date: Sun, 15 Oct 2023 15:44:52 -0700 Subject: [PATCH 476/968] Remove code owner from withings (#102081) * Remove code owner from withings * Update CODEOWNERS --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 ++-- homeassistant/components/withings/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 33f5fb766b4..95c5b7bedcf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1438,8 +1438,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj /homeassistant/components/wirelesstag/ @sergeymaysak -/homeassistant/components/withings/ @vangorra @joostlek -/tests/components/withings/ @vangorra @joostlek +/homeassistant/components/withings/ @joostlek +/tests/components/withings/ @joostlek /homeassistant/components/wiz/ @sbidy /tests/components/wiz/ @sbidy /homeassistant/components/wled/ @frenck diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 3adbdbed0b3..0b89df4af7b 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -2,7 +2,7 @@ "domain": "withings", "name": "Withings", "after_dependencies": ["cloud"], - "codeowners": ["@vangorra", "@joostlek"], + "codeowners": ["@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", From ce7573c3ad9eed5834c08f39dd6cefe8e51fc3b9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 15 Oct 2023 16:59:19 -0700 Subject: [PATCH 477/968] Fix bug in calendar state transitions (#102083) --- homeassistant/components/calendar/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 639a56cd658..65a61e71d3a 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -483,7 +483,7 @@ class CalendarEntity(Entity): _entity_component_unrecorded_attributes = frozenset({"description"}) - _alarm_unsubs: list[CALLBACK_TYPE] = [] + _alarm_unsubs: list[CALLBACK_TYPE] | None = None @property def event(self) -> CalendarEvent | None: @@ -528,6 +528,8 @@ class CalendarEntity(Entity): the current or upcoming event. """ super().async_write_ha_state() + if self._alarm_unsubs is None: + self._alarm_unsubs = [] _LOGGER.debug( "Clearing %s alarms (%s)", self.entity_id, len(self._alarm_unsubs) ) @@ -571,9 +573,9 @@ class CalendarEntity(Entity): To be extended by integrations. """ - for unsub in self._alarm_unsubs: + for unsub in self._alarm_unsubs or (): unsub() - self._alarm_unsubs.clear() + self._alarm_unsubs = None async def async_get_events( self, From c60cc1150526062ee5ca1268b7ef2b6933fdd4b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 16 Oct 2023 01:00:19 +0100 Subject: [PATCH 478/968] Call disconnected callbacks from BT ESPHome client (#102084) Co-authored-by: J. Nick Koston --- homeassistant/components/esphome/bluetooth/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index d44d331248b..970e866b27b 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -392,8 +392,8 @@ class ESPHomeClient(BaseBleakClient): return await self._disconnect() async def _disconnect(self) -> bool: - self._async_disconnected_cleanup() await self._client.bluetooth_device_disconnect(self._address_as_int) + self._async_ble_device_disconnected() await self._wait_for_free_connection_slot(DISCONNECT_TIMEOUT) return True From d0ba42283c6ccddd15f703dee9880e99c6513f2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 15:24:49 -1000 Subject: [PATCH 479/968] Use stdlib ip_address method in the network helper when compatible (#102019) --- homeassistant/util/network.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index f92396f57df..46eaece25c4 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -7,10 +7,12 @@ import re import yarl # RFC6890 - IP addresses of loopback interfaces +IPV6_IPV4_LOOPBACK = ip_network("::ffff:127.0.0.0/104") + LOOPBACK_NETWORKS = ( ip_network("127.0.0.0/8"), ip_network("::1/128"), - ip_network("::ffff:127.0.0.0/104"), + IPV6_IPV4_LOOPBACK, ) # RFC6890 - Address allocation for Private Internets @@ -34,7 +36,7 @@ LINK_LOCAL_NETWORKS = ( def is_loopback(address: IPv4Address | IPv6Address) -> bool: """Check if an address is a loopback address.""" - return any(address in network for network in LOOPBACK_NETWORKS) + return address.is_loopback or address in IPV6_IPV4_LOOPBACK def is_private(address: IPv4Address | IPv6Address) -> bool: @@ -44,7 +46,7 @@ def is_private(address: IPv4Address | IPv6Address) -> bool: def is_link_local(address: IPv4Address | IPv6Address) -> bool: """Check if an address is link-local (local but not necessarily unique).""" - return any(address in network for network in LINK_LOCAL_NETWORKS) + return address.is_link_local def is_local(address: IPv4Address | IPv6Address) -> bool: @@ -54,7 +56,7 @@ def is_local(address: IPv4Address | IPv6Address) -> bool: def is_invalid(address: IPv4Address | IPv6Address) -> bool: """Check if an address is invalid.""" - return bool(address == ip_address("0.0.0.0")) + return address.is_unspecified def is_ip_address(address: str) -> bool: From 17c9d85e0ef3a17c4f7151730e64ed1f40e5449b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 16:32:55 -1000 Subject: [PATCH 480/968] Bump aioesphomeapi to 18.0.3 (#102085) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 463404fae1c..f203f8323bd 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.1", + "aioesphomeapi==18.0.3", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index f9fff2bcd8a..764302218ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.1 +aioesphomeapi==18.0.3 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e2b3be4f6a..58e722c4064 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.1 +aioesphomeapi==18.0.3 # homeassistant.components.flo aioflo==2021.11.0 From 88296c1998fd1943576e0167ab190d25af175257 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 15 Oct 2023 17:05:20 -1000 Subject: [PATCH 481/968] Migrate ESPHome unique ids to new format (#99451) --- homeassistant/components/esphome/entity.py | 10 +- .../components/esphome/entry_data.py | 27 +++-- homeassistant/components/esphome/manager.py | 9 +- tests/components/esphome/test_entry_data.py | 110 ++++++++++++++++++ tests/components/esphome/test_sensor.py | 8 +- 5 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 tests/components/esphome/test_entry_data.py diff --git a/homeassistant/components/esphome/entity.py b/homeassistant/components/esphome/entity.py index db300ab1b28..dc5a4ff0968 100644 --- a/homeassistant/components/esphome/entity.py +++ b/homeassistant/components/esphome/entity.py @@ -4,12 +4,13 @@ from __future__ import annotations from collections.abc import Callable import functools import math -from typing import Any, Generic, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast from aioesphomeapi import ( EntityCategory as EsphomeEntityCategory, EntityInfo, EntityState, + build_unique_id, ) import voluptuous as vol @@ -215,9 +216,12 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): This method can be overridden in child classes to know when the static info changes. """ - static_info = cast(_InfoT, static_info) + device_info = self._entry_data.device_info + if TYPE_CHECKING: + static_info = cast(_InfoT, static_info) + assert device_info self._static_info = static_info - self._attr_unique_id = static_info.unique_id + self._attr_unique_id = build_unique_id(device_info.mac_address, static_info) self._attr_entity_registry_enabled_default = not static_info.disabled_by_default self._attr_name = static_info.name if entity_category := static_info.entity_category: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index ad9403e3601..21a8141647d 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -31,16 +31,19 @@ from aioesphomeapi import ( SwitchInfo, TextSensorInfo, UserService, + build_unique_id, ) from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store from .bluetooth.device import ESPHomeBluetoothDevice +from .const import DOMAIN from .dashboard import async_get_dashboard INFO_TO_COMPONENT_TYPE: Final = {v: k for k, v in COMPONENT_TYPE_TO_INFO.items()} @@ -244,24 +247,34 @@ class RuntimeEntryData: self.loaded_platforms |= needed async def async_update_static_infos( - self, hass: HomeAssistant, entry: ConfigEntry, infos: list[EntityInfo] + self, hass: HomeAssistant, entry: ConfigEntry, infos: list[EntityInfo], mac: str ) -> None: """Distribute an update of static infos to all platforms.""" # First, load all platforms needed_platforms = set() - if async_get_dashboard(hass): needed_platforms.add(Platform.UPDATE) - if self.device_info is not None and self.device_info.voice_assistant_version: + if self.device_info and self.device_info.voice_assistant_version: needed_platforms.add(Platform.BINARY_SENSOR) needed_platforms.add(Platform.SELECT) + ent_reg = er.async_get(hass) + registry_get_entity = ent_reg.async_get_entity_id for info in infos: - for info_type, platform in INFO_TYPE_TO_PLATFORM.items(): - if isinstance(info, info_type): - needed_platforms.add(platform) - break + platform = INFO_TYPE_TO_PLATFORM[type(info)] + needed_platforms.add(platform) + # If the unique id is in the old format, migrate it + # except if they downgraded and upgraded, there might be a duplicate + # so we want to keep the one that was already there. + if ( + (old_unique_id := info.unique_id) + and (old_entry := registry_get_entity(platform, DOMAIN, old_unique_id)) + and (new_unique_id := build_unique_id(mac, info)) != old_unique_id + and not registry_get_entity(platform, DOMAIN, new_unique_id) + ): + ent_reg.async_update_entity(old_entry, new_unique_id=new_unique_id) + await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Make a dict of the EntityInfo by type and send diff --git a/homeassistant/components/esphome/manager.py b/homeassistant/components/esphome/manager.py index 211404431c0..812cf430d09 100644 --- a/homeassistant/components/esphome/manager.py +++ b/homeassistant/components/esphome/manager.py @@ -450,7 +450,9 @@ class ESPHomeManager: try: entity_infos, services = await cli.list_entities_services() - await entry_data.async_update_static_infos(hass, entry, entity_infos) + await entry_data.async_update_static_infos( + hass, entry, entity_infos, device_info.mac_address + ) await _setup_services(hass, entry_data, services) await cli.subscribe_states(entry_data.async_update_state) await cli.subscribe_service_calls(self.async_on_service_call) @@ -544,7 +546,10 @@ class ESPHomeManager: self.reconnect_logic = reconnect_logic infos, services = await entry_data.async_load_from_store() - await entry_data.async_update_static_infos(hass, entry, infos) + if entry.unique_id: + await entry_data.async_update_static_infos( + hass, entry, infos, entry.unique_id.upper() + ) await _setup_services(hass, entry_data, services) if entry_data.device_info is not None and entry_data.device_info.name: diff --git a/tests/components/esphome/test_entry_data.py b/tests/components/esphome/test_entry_data.py new file mode 100644 index 00000000000..64484b91e07 --- /dev/null +++ b/tests/components/esphome/test_entry_data.py @@ -0,0 +1,110 @@ +"""Test ESPHome entry data.""" + +from aioesphomeapi import ( + APIClient, + EntityCategory as ESPHomeEntityCategory, + SensorInfo, + SensorState, +) + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def test_migrate_entity_unique_id( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic sensor entity unique id migration.""" + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "my_sensor", + suggested_object_id="old_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.old_sensor") + assert state is not None + assert state.state == "50" + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.old_sensor") + assert entry is not None + assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is None + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" + + +async def test_migrate_entity_unique_id_downgrade_upgrade( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test unique id migration prefers the original entity on downgrade upgrade.""" + ent_reg = er.async_get(hass) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "my_sensor", + suggested_object_id="old_sensor", + disabled_by=None, + ) + ent_reg.async_get_or_create( + "sensor", + "esphome", + "11:22:33:44:55:aa-sensor-mysensor", + suggested_object_id="new_sensor", + disabled_by=None, + ) + entity_info = [ + SensorInfo( + object_id="mysensor", + key=1, + name="my sensor", + unique_id="my_sensor", + entity_category=ESPHomeEntityCategory.DIAGNOSTIC, + icon="mdi:leaf", + ) + ] + states = [SensorState(key=1, state=50)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("sensor.new_sensor") + assert state is not None + assert state.state == "50" + entity_reg = er.async_get(hass) + entry = entity_reg.async_get("sensor.new_sensor") + assert entry is not None + # Confirm we did not touch the entity that was created + # on downgrade so when they upgrade again they can delete the + # entity that was only created on downgrade and they keep + # the original one. + assert entity_reg.async_get_entity_id("sensor", "esphome", "my_sensor") is not None + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" diff --git a/tests/components/esphome/test_sensor.py b/tests/components/esphome/test_sensor.py index cf7e2af02d7..820ec9ad9c0 100644 --- a/tests/components/esphome/test_sensor.py +++ b/tests/components/esphome/test_sensor.py @@ -116,7 +116,9 @@ async def test_generic_numeric_sensor_with_entity_category_and_icon( entity_reg = er.async_get(hass) entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None - assert entry.unique_id == "my_sensor" + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" assert entry.entity_category is EntityCategory.DIAGNOSTIC @@ -152,7 +154,9 @@ async def test_generic_numeric_sensor_state_class_measurement( entity_reg = er.async_get(hass) entry = entity_reg.async_get("sensor.test_mysensor") assert entry is not None - assert entry.unique_id == "my_sensor" + # Note that ESPHome includes the EntityInfo type in the unique id + # as this is not a 1:1 mapping to the entity platform (ie. text_sensor) + assert entry.unique_id == "11:22:33:44:55:aa-sensor-mysensor" assert entry.entity_category is None From 6b05f51413e491e05f17f1c8d841dd1eec5e0f8e Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 16 Oct 2023 10:28:11 +0200 Subject: [PATCH 482/968] Migrate unique id in Trafikverket Camera (#101937) --- .../trafikverket_camera/__init__.py | 44 ++++- .../trafikverket_camera/config_flow.py | 16 +- .../trafikverket_camera/conftest.py | 3 +- .../trafikverket_camera/test_config_flow.py | 6 +- .../trafikverket_camera/test_coordinator.py | 9 +- .../trafikverket_camera/test_init.py | 152 +++++++++++++++++- 6 files changed, 215 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 5575f32788a..d9d28cfe13b 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -1,15 +1,23 @@ """The trafikverket_camera component.""" from __future__ import annotations +import logging + +from pytrafikverket.trafikverket_camera import TrafikverketCamera + from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS +from .const import CONF_LOCATION, DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" @@ -30,3 +38,37 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + # Change entry unique id from location to camera id + if entry.version == 1: + location = entry.data[CONF_LOCATION] + api_key = entry.data[CONF_API_KEY] + + web_session = async_get_clientsession(hass) + camera_api = TrafikverketCamera(web_session, api_key) + + try: + camera_info = await camera_api.async_get_camera(location) + except Exception: # pylint: disable=broad-except + _LOGGER.error( + "Could not migrate the config entry. No connection to the api" + ) + return False + + if camera_id := camera_info.camera_id: + entry.version = 2 + _LOGGER.debug( + "Migrate Trafikverket Camera config entry unique id to %s", + camera_id, + ) + hass.config_entries.async_update_entry( + entry, + unique_id=f"{DOMAIN}-{camera_id}", + ) + return True + _LOGGER.error("Could not migrate the config entry. Camera has no id") + return False + return True diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index d4a282cb344..e75bc0bfa30 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -25,17 +25,18 @@ from .const import CONF_LOCATION, DOMAIN class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Trafikverket Camera integration.""" - VERSION = 1 + VERSION = 2 entry: config_entries.ConfigEntry | None async def validate_input( self, sensor_api: str, location: str - ) -> tuple[dict[str, str], str | None]: + ) -> tuple[dict[str, str], str | None, str | None]: """Validate input from user input.""" errors: dict[str, str] = {} camera_info: CameraInfo | None = None camera_location: str | None = None + camera_id: str | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) @@ -51,12 +52,13 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if camera_info: + camera_id = camera_info.camera_id if _location := camera_info.location: camera_location = _location else: camera_location = camera_info.camera_name - return (errors, camera_location) + return (errors, camera_location, camera_id) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -74,7 +76,7 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors, _ = await self.validate_input( + errors, _, _ = await self.validate_input( api_key, self.entry.data[CONF_LOCATION] ) @@ -109,11 +111,13 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors, camera_location = await self.validate_input(api_key, location) + errors, camera_location, camera_id = await self.validate_input( + api_key, location + ) if not errors: assert camera_location - await self.async_set_unique_id(f"{DOMAIN}-{camera_location}") + await self.async_set_unique_id(f"{DOMAIN}-{camera_id}") self._abort_if_unique_id_configured() return self.async_create_entry( title=camera_location, diff --git a/tests/components/trafikverket_camera/conftest.py b/tests/components/trafikverket_camera/conftest.py index 95c145bbeb3..a4902ac2950 100644 --- a/tests/components/trafikverket_camera/conftest.py +++ b/tests/components/trafikverket_camera/conftest.py @@ -32,7 +32,8 @@ async def load_integration_from_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index ae3410d20b3..b53763c0ac7 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -53,7 +53,7 @@ async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: "location": "Test location", } assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "trafikverket_camera-Test location" + assert result2["result"].unique_id == "trafikverket_camera-1234" async def test_form_no_location_data( @@ -90,7 +90,7 @@ async def test_form_no_location_data( "location": "Test Camera", } assert len(mock_setup_entry.mock_calls) == 1 - assert result2["result"].unique_id == "trafikverket_camera-Test Camera" + assert result2["result"].unique_id == "trafikverket_camera-1234" @pytest.mark.parametrize( @@ -153,6 +153,7 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: CONF_LOCATION: "Test location", }, unique_id="1234", + version=2, ) entry.add_to_hass(hass) @@ -225,6 +226,7 @@ async def test_reauth_flow_error( CONF_LOCATION: "Test location", }, unique_id="1234", + version=2, ) entry.add_to_hass(hass) await hass.async_block_till_done() diff --git a/tests/components/trafikverket_camera/test_coordinator.py b/tests/components/trafikverket_camera/test_coordinator.py index 2b21ce935b2..4183aa9fffa 100644 --- a/tests/components/trafikverket_camera/test_coordinator.py +++ b/tests/components/trafikverket_camera/test_coordinator.py @@ -40,7 +40,8 @@ async def test_coordinator( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -100,7 +101,8 @@ async def test_coordinator_failed_update( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -133,7 +135,8 @@ async def test_coordinator_failed_get_image( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) diff --git a/tests/components/trafikverket_camera/test_init.py b/tests/components/trafikverket_camera/test_init.py index d9de0a830a6..83a3fc1486a 100644 --- a/tests/components/trafikverket_camera/test_init.py +++ b/tests/components/trafikverket_camera/test_init.py @@ -1,14 +1,18 @@ """Test for Trafikverket Ferry component Init.""" from __future__ import annotations +from datetime import datetime from unittest.mock import patch +from pytrafikverket.exceptions import UnknownError from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries +from homeassistant.components.trafikverket_camera import async_migrate_entry from homeassistant.components.trafikverket_camera.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -31,7 +35,8 @@ async def test_setup_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="123", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -62,7 +67,8 @@ async def test_unload_entry( source=SOURCE_USER, data=ENTRY_CONFIG, entry_id="1", - unique_id="321", + version=2, + unique_id="trafikverket_camera-1234", title="Test location", ) entry.add_to_hass(hass) @@ -78,3 +84,145 @@ async def test_unload_entry( assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + + +async def test_migrate_entry( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry to version 2.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert entry.version == 2 + assert entry.unique_id == "trafikverket_camera-1234" + assert len(mock_tvt_camera.mock_calls) == 2 + + +async def test_migrate_entry_fails_with_error( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails with api error.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + side_effect=UnknownError, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 + assert entry.unique_id == "trafikverket_camera-Test location" + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_migrate_entry_fails_no_id( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails, camera returns no id.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="trafikverket_camera-Test location", + title="Test location", + ) + entry.add_to_hass(hass) + + _camera = CameraInfo( + camera_name="Test_camera", + camera_id=None, + active=True, + deleted=False, + description="Test Camera for testing", + direction="180", + fullsizephoto=True, + location="Test location", + modified=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + phototime=datetime(2022, 4, 4, 4, 4, 4, tzinfo=dt_util.UTC), + photourl="https://www.testurl.com/test_photo.jpg", + status="Running", + camera_type="Road", + ) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=_camera, + ) as mock_tvt_camera: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.MIGRATION_ERROR + assert entry.version == 1 + assert entry.unique_id == "trafikverket_camera-Test location" + assert len(mock_tvt_camera.mock_calls) == 1 + + +async def test_no_migration_needed( + hass: HomeAssistant, + get_camera: CameraInfo, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test migrate entry fails, camera returns no id.""" + aioclient_mock.get( + "https://www.testurl.com/test_photo.jpg?type=fullsize", content=b"0123456789" + ) + + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + version=2, + entry_id="1234", + unique_id="trafikverket_camera-1234", + title="Test location", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_camera.coordinator.TrafikverketCamera.async_get_camera", + return_value=get_camera, + ): + assert await async_migrate_entry(hass, entry) is True From eaf6197d430435b03b4f3fcb3d32dfd938cb4bd8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 16 Oct 2023 07:41:45 -0400 Subject: [PATCH 483/968] Bump Blinkpy to 0.22.2 in Blink (#98571) --- homeassistant/components/blink/__init__.py | 86 +++++++++---------- .../components/blink/alarm_control_panel.py | 57 ++++++++---- .../components/blink/binary_sensor.py | 13 +-- homeassistant/components/blink/camera.py | 38 +++++--- homeassistant/components/blink/config_flow.py | 28 +++--- homeassistant/components/blink/manifest.json | 2 +- homeassistant/components/blink/sensor.py | 16 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/blink/test_config_flow.py | 7 +- 10 files changed, 144 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index b94a77fbf18..534fff310e3 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -1,7 +1,9 @@ """Support for Blink Home Camera System.""" +import asyncio from copy import deepcopy import logging +from aiohttp import ClientError from blinkpy.auth import Auth from blinkpy.blinkpy import Blink import voluptuous as vol @@ -16,8 +18,9 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( DEFAULT_SCAN_INTERVAL, @@ -40,23 +43,7 @@ SERVICE_SAVE_RECENT_CLIPS_SCHEMA = vol.Schema( ) -def _blink_startup_wrapper(hass: HomeAssistant, entry: ConfigEntry) -> Blink: - """Startup wrapper for blink.""" - blink = Blink() - auth_data = deepcopy(dict(entry.data)) - blink.auth = Auth(auth_data, no_prompt=True) - blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - - if blink.start(): - blink.setup_post_verify() - elif blink.auth.check_key_required(): - _LOGGER.debug("Attempting a reauth flow") - _reauth_flow_wrapper(hass, auth_data) - - return blink - - -def _reauth_flow_wrapper(hass, data): +async def _reauth_flow_wrapper(hass, data): """Reauth flow wrapper.""" hass.add_job( hass.config_entries.flow.async_init( @@ -79,10 +66,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data = {**entry.data} if entry.version == 1: data.pop("login_response", None) - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False if entry.version == 2: - await hass.async_add_executor_job(_reauth_flow_wrapper, hass, data) + await _reauth_flow_wrapper(hass, data) return False return True @@ -92,19 +79,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {}) _async_import_options_from_data_if_missing(hass, entry) - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( - _blink_startup_wrapper, hass, entry - ) + session = async_get_clientsession(hass) + blink = Blink(session=session) + auth_data = deepcopy(dict(entry.data)) + blink.auth = Auth(auth_data, no_prompt=True, session=session) + blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - if not hass.data[DOMAIN][entry.entry_id].available: + try: + await blink.start() + if blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + raise ConfigEntryAuthFailed("Need 2FA for Blink") + except (ClientError, asyncio.TimeoutError) as ex: + raise ConfigEntryNotReady("Can not connect to host") from ex + + hass.data[DOMAIN][entry.entry_id] = blink + + if not blink.available: raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) + await blink.refresh(force=True) - def blink_refresh(event_time=None): + async def blink_refresh(event_time=None): """Call blink to refresh info.""" - hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) + await hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) async def async_save_video(call): """Call save video service handler.""" @@ -114,10 +114,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Call save recent clips service handler.""" await async_handle_save_recent_clips_service(hass, entry, call) - def send_pin(call): + async def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( + await hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( hass.data[DOMAIN][entry.entry_id], pin, ) @@ -176,27 +176,27 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] -async def async_handle_save_video_service(hass, entry, call): +async def async_handle_save_video_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Handle save video service calls.""" camera_name = call.data[CONF_NAME] video_path = call.data[CONF_FILENAME] if not hass.config.is_allowed_path(video_path): _LOGGER.error("Can't write %s, no access to path!", video_path) return - - def _write_video(name, file_path): - """Call video write.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].video_to_file(file_path) - try: - await hass.async_add_executor_job(_write_video, camera_name, video_path) + all_cameras = hass.data[DOMAIN][entry.entry_id].cameras + if camera_name in all_cameras: + await all_cameras[camera_name].video_to_file(video_path) + except OSError as err: _LOGGER.error("Can't write image to file: %s", err) -async def async_handle_save_recent_clips_service(hass, entry, call): +async def async_handle_save_recent_clips_service( + hass: HomeAssistant, entry: ConfigEntry, call +) -> None: """Save multiple recent clips to output directory.""" camera_name = call.data[CONF_NAME] clips_dir = call.data[CONF_FILE_PATH] @@ -204,13 +204,9 @@ async def async_handle_save_recent_clips_service(hass, entry, call): _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) return - def _save_recent_clips(name, output_dir): - """Call save recent clips.""" - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if name in all_cameras: - all_cameras[name].save_recent_clips(output_dir=output_dir) - try: - await hass.async_add_executor_job(_save_recent_clips, camera_name, clips_dir) + all_cameras = hass.data[DOMAIN][entry.entry_id].cameras + if camera_name in all_cameras: + await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) except OSError as err: _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 16a8c00d67a..b69f9b84670 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,8 +1,11 @@ """Support for Blink Alarm Control Panel.""" from __future__ import annotations +import asyncio import logging +from blinkpy.blinkpy import Blink + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -16,6 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN @@ -32,11 +36,11 @@ async def async_setup_entry( sync_modules = [] for sync_name, sync_module in data.sync.items(): - sync_modules.append(BlinkSyncModule(data, sync_name, sync_module)) - async_add_entities(sync_modules) + sync_modules.append(BlinkSyncModuleHA(data, sync_name, sync_module)) + async_add_entities(sync_modules, update_before_add=True) -class BlinkSyncModule(AlarmControlPanelEntity): +class BlinkSyncModuleHA(AlarmControlPanelEntity): """Representation of a Blink Alarm Control Panel.""" _attr_icon = ICON @@ -44,19 +48,19 @@ class BlinkSyncModule(AlarmControlPanelEntity): _attr_name = None _attr_has_entity_name = True - def __init__(self, data, name, sync): + def __init__(self, data, name: str, sync) -> None: """Initialize the alarm control panel.""" - self.data = data + self.data: Blink = data self.sync = sync - self._name = name - self._attr_unique_id = sync.serial + self._name: str = name + self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sync.serial)}, name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, ) - def update(self) -> None: + async def async_update(self) -> None: """Update the state of the device.""" if self.data.check_if_ok_to_update(): _LOGGER.debug( @@ -64,23 +68,38 @@ class BlinkSyncModule(AlarmControlPanelEntity): self._name, self.data, ) - self.data.refresh() + try: + await self.data.refresh(force=True) + self._attr_available = True + except asyncio.TimeoutError: + self._attr_available = False + _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) - self._attr_state = ( - STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED - ) self.sync.attributes["network_info"] = self.data.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes - def alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - self.sync.arm = False - self.sync.refresh() + @property + def state(self) -> StateType: + """Return state of alarm.""" + return STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED - def alarm_arm_away(self, code: str | None = None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: + """Send disarm command.""" + try: + await self.sync.async_arm(False) + await self.sync.refresh(force=True) + self.async_write_ha_state() + except asyncio.TimeoutError: + self._attr_available = False + + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" - self.sync.arm = True - self.sync.refresh() + try: + await self.sync.async_arm(True) + await self.sync.refresh(force=True) + self.async_write_ha_state() + except asyncio.TimeoutError: + self._attr_available = False diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 1b53a11b1d2..1edb8b91336 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -52,7 +52,7 @@ async def async_setup_entry( for camera in data.cameras for description in BINARY_SENSORS_TYPES ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) class BlinkBinarySensor(BinarySensorEntity): @@ -75,15 +75,16 @@ class BlinkBinarySensor(BinarySensorEntity): model=self._camera.camera_type, ) - def update(self) -> None: + @property + def is_on(self) -> bool | None: """Update sensor state.""" - state = self._camera.attributes[self.entity_description.key] + is_on = self._camera.attributes[self.entity_description.key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], self.entity_description.key, - state, + is_on, ) if self.entity_description.key == TYPE_BATTERY: - state = state != "ok" - self._attr_is_on = state + is_on = is_on != "ok" + return is_on diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 9f9396c3888..1a28d52356e 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -1,7 +1,10 @@ """Support for Blink system camera.""" from __future__ import annotations +import asyncio +from collections.abc import Mapping import logging +from typing import Any from requests.exceptions import ChunkedEncodingError @@ -29,7 +32,7 @@ async def async_setup_entry( BlinkCamera(data, name, camera) for name, camera in data.cameras.items() ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") @@ -56,19 +59,25 @@ class BlinkCamera(Camera): _LOGGER.debug("Initialized blink camera %s", self.name) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return the camera attributes.""" return self._camera.attributes - def enable_motion_detection(self) -> None: + async def async_enable_motion_detection(self) -> None: """Enable motion detection for the camera.""" - self._camera.arm = True - self.data.refresh() + try: + await self._camera.async_arm(True) + await self.data.refresh(force=True) + except asyncio.TimeoutError: + self._attr_available = False - def disable_motion_detection(self) -> None: + async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" - self._camera.arm = False - self.data.refresh() + try: + await self._camera.async_arm(False) + await self.data.refresh(force=True) + except asyncio.TimeoutError: + self._attr_available = False @property def motion_detection_enabled(self) -> bool: @@ -76,21 +85,24 @@ class BlinkCamera(Camera): return self._camera.arm @property - def brand(self): + def brand(self) -> str | None: """Return the camera brand.""" return DEFAULT_BRAND - def trigger_camera(self): + async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - self._camera.snap_picture() - self.data.refresh() + try: + await self._camera.snap_picture() + self.async_schedule_update_ha_state(force_refresh=True) + except asyncio.TimeoutError: + pass def camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" try: - return self._camera.image_from_cache.content + return self._camera.image_from_cache except ChunkedEncodingError: _LOGGER.debug("Could not retrieve image for %s", self._camera.name) return None diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index d3b2878b522..cc740d8be31 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -16,10 +16,11 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_USERNAME, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import selector +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.schema_config_entry_flow import ( SchemaFlowFormStep, SchemaOptionsFlowHandler, @@ -49,23 +50,23 @@ OPTIONS_FLOW = { } -def validate_input(auth: Auth) -> None: +async def validate_input(auth: Auth) -> None: """Validate the user input allows us to connect.""" try: - auth.startup() + await auth.startup() except (LoginError, TokenRefreshFailed) as err: raise InvalidAuth from err if auth.check_key_required(): raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: +async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str) -> bool: """Send 2FA pin to blink servers.""" - blink = Blink() + blink = Blink(session=async_get_clientsession(hass)) blink.auth = auth blink.setup_login_ids() blink.setup_urls() - return auth.send_auth_key(blink, pin) + return await auth.send_auth_key(blink, pin) class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): @@ -91,11 +92,15 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a flow initiated by the user.""" errors = {} if user_input is not None: - self.auth = Auth({**user_input, "device_id": DEVICE_ID}, no_prompt=True) + self.auth = Auth( + {**user_input, "device_id": DEVICE_ID}, + no_prompt=True, + session=async_get_clientsession(self.hass), + ) await self.async_set_unique_id(user_input[CONF_USERNAME]) try: - await self.hass.async_add_executor_job(validate_input, self.auth) + await validate_input(self.auth) return self._async_finish_flow() except Require2FA: return await self.async_step_2fa() @@ -122,12 +127,9 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle 2FA step.""" errors = {} if user_input is not None: - pin: str | None = user_input.get(CONF_PIN) + pin: str = str(user_input.get(CONF_PIN)) try: - assert self.auth - valid_token = await self.hass.async_add_executor_job( - _send_blink_2fa_pin, self.auth, pin - ) + valid_token = await _send_blink_2fa_pin(self.hass, self.auth, pin) except BlinkSetupError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 302a9f1e86a..54f36ec6e2e 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.21.0"] + "requirements": ["blinkpy==0.22.2"] } diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index ceec74a9aa9..e4fdabc29d1 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,12 +1,15 @@ """Support for Blink system camera sensors.""" from __future__ import annotations +from datetime import date, datetime +from decimal import Decimal import logging from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -17,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH @@ -28,6 +32,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, device_class=SensorDeviceClass.TEMPERATURE, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=TYPE_WIFI_STRENGTH, @@ -35,6 +40,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, ), ) @@ -50,7 +56,7 @@ async def async_setup_entry( for description in SENSOR_TYPES ] - async_add_entities(entities) + async_add_entities(entities, update_before_add=True) class BlinkSensor(SensorEntity): @@ -76,10 +82,11 @@ class BlinkSensor(SensorEntity): model=self._camera.camera_type, ) - def update(self) -> None: + @property + def native_value(self) -> StateType | date | datetime | Decimal: """Retrieve sensor data from the camera.""" try: - self._attr_native_value = self._camera.attributes[self._sensor_key] + native_value = self._camera.attributes[self._sensor_key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], @@ -87,7 +94,8 @@ class BlinkSensor(SensorEntity): self._attr_native_value, ) except KeyError: - self._attr_native_value = None + native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) + return native_value diff --git a/requirements_all.txt b/requirements_all.txt index 764302218ca..1ae045131e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bitcoin blockchain==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 58e722c4064..20a6d47cbcb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -460,7 +460,7 @@ bleak==0.21.1 blebox-uniapi==2.2.0 # homeassistant.components.blink -blinkpy==0.21.0 +blinkpy==0.22.2 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 8b1e13aaa70..0809a674600 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -1,5 +1,5 @@ """Test the Blink config flow.""" -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from blinkpy.auth import LoginError from blinkpy.blinkpy import BlinkSetupError @@ -268,10 +268,10 @@ async def test_options_flow(hass: HomeAssistant) -> None: ) config_entry.add_to_hass(hass) - mock_auth = Mock( + mock_auth = AsyncMock( startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) - mock_blink = Mock() + mock_blink = AsyncMock() with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( "homeassistant.components.blink.Blink", return_value=mock_blink @@ -293,7 +293,6 @@ async def test_options_flow(hass: HomeAssistant) -> None: result["flow_id"], user_input={"scan_interval": 5}, ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == {"scan_interval": 5} await hass.async_block_till_done() From b8904fa173e3100aaf9ccaeed808f04badd48530 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:43:01 +0200 Subject: [PATCH 484/968] Update device class, icons and names of Vicare binary sensors (#101476) Co-authored-by: Joost Lekkerkerker --- .../components/vicare/binary_sensor.py | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 17cee394ade..c45192f05b4 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -38,14 +38,15 @@ class ViCareBinarySensorEntityDescription( CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="circulationpump_active", - name="Circulation pump active", - device_class=BinarySensorDeviceClass.POWER, + name="Circulation pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="frost_protection_active", - name="Frost protection active", - device_class=BinarySensorDeviceClass.POWER, + name="Frost protection", + icon="mdi:snowflake", value_getter=lambda api: api.getFrostProtectionActive(), ), ) @@ -53,8 +54,9 @@ CIRCUIT_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="burner_active", - name="Burner active", - device_class=BinarySensorDeviceClass.POWER, + name="Burner", + icon="mdi:gas-burner", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), ) @@ -62,8 +64,8 @@ BURNER_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="compressor_active", - name="Compressor active", - device_class=BinarySensorDeviceClass.POWER, + name="Compressor", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getActive(), ), ) @@ -71,26 +73,29 @@ COMPRESSOR_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( ViCareBinarySensorEntityDescription( key="solar_pump_active", - name="Solar pump active", - device_class=BinarySensorDeviceClass.POWER, + name="Solar pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getSolarPumpActive(), ), ViCareBinarySensorEntityDescription( key="charging_active", - name="DHW Charging active", + name="DHW Charging", device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterChargingActive(), ), ViCareBinarySensorEntityDescription( key="dhw_circulationpump_active", - name="DHW Circulation Pump Active", - device_class=BinarySensorDeviceClass.POWER, + name="DHW Circulation Pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterCirculationPumpActive(), ), ViCareBinarySensorEntityDescription( key="dhw_pump_active", - name="DHW Pump Active", - device_class=BinarySensorDeviceClass.POWER, + name="DHW Pump", + icon="mdi:pump", + device_class=BinarySensorDeviceClass.RUNNING, value_getter=lambda api: api.getDomesticHotWaterPumpActive(), ), ) From 79811e3cd9d486bead5520735744114c672fada3 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 16 Oct 2023 14:22:01 +0200 Subject: [PATCH 485/968] Bump velbusaio to 2023.10.0 (#102100) --- 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 220c416cfe9..82b66cc0e7f 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.2.0"], + "requirements": ["velbus-aio==2023.10.0"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 1ae045131e1..f4e947558c3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2660,7 +2660,7 @@ vallox-websocket-api==3.3.0 vehicle==1.0.1 # homeassistant.components.velbus -velbus-aio==2023.2.0 +velbus-aio==2023.10.0 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 20a6d47cbcb..39a3ab61a78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1978,7 +1978,7 @@ vallox-websocket-api==3.3.0 vehicle==1.0.1 # homeassistant.components.velbus -velbus-aio==2023.2.0 +velbus-aio==2023.10.0 # homeassistant.components.venstar venstarcolortouch==0.19 From 4913e7e84699a57f5ec5f5e4ff687cd14787d53d Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 16 Oct 2023 14:23:43 +0200 Subject: [PATCH 486/968] Correct sensor state attribute and device class in Velbus sensors (#102099) --- homeassistant/components/velbus/sensor.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 0805ae2699a..8e1f8bba74a 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -45,25 +45,18 @@ class VelbusSensor(VelbusEntity, SensorEntity): """Initialize a sensor Velbus entity.""" super().__init__(channel) self._is_counter: bool = counter - # define the unique id if self._is_counter: - self._attr_unique_id = f"{self._attr_unique_id}-counter" - # define the name - if self._is_counter: - self._attr_name = f"{self._attr_name}-counter" - # define the device class - if self._is_counter: - self._attr_device_class = SensorDeviceClass.POWER - elif channel.is_counter_channel(): self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_icon = "mdi:counter" + self._attr_name = f"{self._attr_name}-counter" + self._attr_state_class = SensorStateClass.TOTAL_INCREASING + self._attr_unique_id = f"{self._attr_unique_id}-counter" + elif channel.is_counter_channel(): + self._attr_device_class = SensorDeviceClass.POWER + self._attr_state_class = SensorStateClass.MEASUREMENT elif channel.is_temperature(): self._attr_device_class = SensorDeviceClass.TEMPERATURE - # define the icon - if self._is_counter: - self._attr_icon = "mdi:counter" - # the state class - if self._is_counter: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING + self._attr_state_class = SensorStateClass.MEASUREMENT else: self._attr_state_class = SensorStateClass.MEASUREMENT # unit From e151358aa13f0c61186e0a10dc0148e6030b5cd6 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Mon, 16 Oct 2023 14:30:05 +0200 Subject: [PATCH 487/968] Allow model-specific lazy_discover setting for xiaomi_miio (#100490) --- homeassistant/components/xiaomi_miio/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py index 0291ca2c8bd..3c316fd3f47 100644 --- a/homeassistant/components/xiaomi_miio/__init__.py +++ b/homeassistant/components/xiaomi_miio/__init__.py @@ -296,10 +296,16 @@ async def async_create_miio_device_and_coordinator( name = entry.title device: MiioDevice | None = None migrate = False - lazy_discover = False update_method = _async_update_data_default coordinator_class: type[DataUpdateCoordinator[Any]] = DataUpdateCoordinator + # List of models requiring specific lazy_discover setting + LAZY_DISCOVER_FOR_MODEL = { + "zhimi.fan.za5": True, + "zhimi.airpurifier.za1": True, + } + lazy_discover = LAZY_DISCOVER_FOR_MODEL.get(model, False) + if ( model not in MODELS_HUMIDIFIER and model not in MODELS_FAN From 9444e1e2abcea624bcdef9e6168617e7f7d75043 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 16 Oct 2023 11:41:56 -0400 Subject: [PATCH 488/968] Address Blink late review (#102106) * Address late review topics * Missing await, optimize config_flow call * Address proper mock for blink * Address typing --- homeassistant/components/blink/__init__.py | 7 ++++--- homeassistant/components/blink/alarm_control_panel.py | 6 ++++-- homeassistant/components/blink/binary_sensor.py | 2 +- homeassistant/components/blink/camera.py | 11 +++++------ homeassistant/components/blink/config_flow.py | 7 ++++--- homeassistant/components/blink/sensor.py | 2 +- tests/components/blink/test_config_flow.py | 2 +- 7 files changed, 20 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 534fff310e3..e57b8e52729 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -87,12 +87,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await blink.start() - if blink.auth.check_key_required(): - _LOGGER.debug("Attempting a reauth flow") - raise ConfigEntryAuthFailed("Need 2FA for Blink") except (ClientError, asyncio.TimeoutError) as ex: raise ConfigEntryNotReady("Can not connect to host") from ex + if blink.auth.check_key_required(): + _LOGGER.debug("Attempting a reauth flow") + raise ConfigEntryAuthFailed("Need 2FA for Blink") + hass.data[DOMAIN][entry.entry_id] = blink if not blink.available: diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index b69f9b84670..2249c9bf16f 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -91,15 +91,17 @@ class BlinkSyncModuleHA(AlarmControlPanelEntity): try: await self.sync.async_arm(False) await self.sync.refresh(force=True) - self.async_write_ha_state() except asyncio.TimeoutError: self._attr_available = False + self.async_write_ha_state() + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" try: await self.sync.async_arm(True) await self.sync.refresh(force=True) - self.async_write_ha_state() except asyncio.TimeoutError: self._attr_available = False + + self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 1edb8b91336..51df22dbf0e 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -52,7 +52,7 @@ async def async_setup_entry( for camera in data.cameras for description in BINARY_SENSORS_TYPES ] - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) class BlinkBinarySensor(BinarySensorEntity): diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 1a28d52356e..bf7af4fe619 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +import contextlib import logging from typing import Any @@ -32,7 +33,7 @@ async def async_setup_entry( BlinkCamera(data, name, camera) for name, camera in data.cameras.items() ] - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) platform = entity_platform.async_get_current_platform() platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") @@ -44,7 +45,7 @@ class BlinkCamera(Camera): _attr_has_entity_name = True _attr_name = None - def __init__(self, data, name, camera): + def __init__(self, data, name, camera) -> None: """Initialize a camera.""" super().__init__() self.data = data @@ -91,11 +92,9 @@ class BlinkCamera(Camera): async def trigger_camera(self) -> None: """Trigger camera to take a snapshot.""" - try: + with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() - self.async_schedule_update_ha_state(force_refresh=True) - except asyncio.TimeoutError: - pass + self.async_write_ha_state() def camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index cc740d8be31..4326c6cb86c 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -60,7 +60,7 @@ async def validate_input(auth: Auth) -> None: raise Require2FA -async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str) -> bool: +async def _send_blink_2fa_pin(hass: HomeAssistant, auth: Auth, pin: str | None) -> bool: """Send 2FA pin to blink servers.""" blink = Blink(session=async_get_clientsession(hass)) blink.auth = auth @@ -127,9 +127,10 @@ class BlinkConfigFlow(ConfigFlow, domain=DOMAIN): """Handle 2FA step.""" errors = {} if user_input is not None: - pin: str = str(user_input.get(CONF_PIN)) try: - valid_token = await _send_blink_2fa_pin(self.hass, self.auth, pin) + valid_token = await _send_blink_2fa_pin( + self.hass, self.auth, user_input.get(CONF_PIN) + ) except BlinkSetupError: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index e4fdabc29d1..c979c9b6a53 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -56,7 +56,7 @@ async def async_setup_entry( for description in SENSOR_TYPES ] - async_add_entities(entities, update_before_add=True) + async_add_entities(entities) class BlinkSensor(SensorEntity): diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 0809a674600..ab04499c827 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -271,7 +271,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: mock_auth = AsyncMock( startup=Mock(return_value=True), check_key_required=Mock(return_value=False) ) - mock_blink = AsyncMock() + mock_blink = AsyncMock(cameras=Mock(), sync=Mock()) with patch("homeassistant.components.blink.Auth", return_value=mock_auth), patch( "homeassistant.components.blink.Blink", return_value=mock_blink From 23b379b7daeaa7a5a7ae05be07fe3f9988edabaf Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Mon, 16 Oct 2023 21:57:37 +0200 Subject: [PATCH 489/968] Bump zha-quirks to 0.0.105 (#102113) --- 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 9ce3a3eb7db..5cde71f8d07 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.104", + "zha-quirks==0.0.105", "zigpy-deconz==0.21.1", "zigpy==0.57.2", "zigpy-xbee==0.18.3", diff --git a/requirements_all.txt b/requirements_all.txt index f4e947558c3..64b489d7f4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2793,7 +2793,7 @@ zeroconf==0.118.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.104 +zha-quirks==0.0.105 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39a3ab61a78..cf1dbf4f2cb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2087,7 +2087,7 @@ zeroconf==0.118.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.104 +zha-quirks==0.0.105 # homeassistant.components.zha zigpy-deconz==0.21.1 From 4130980100bd4fdccd77e2e045809bd73cdb260c Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 16 Oct 2023 23:06:22 +0200 Subject: [PATCH 490/968] Patch library instead of own code in Minecraft Server config flow tests (#102018) * Patch library instead of own code in config flow tests * Fix patch location --- .../minecraft_server/test_config_flow.py | 96 ++++++++++--------- 1 file changed, 51 insertions(+), 45 deletions(-) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index cca6d5d21ac..785905492c1 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -2,17 +2,22 @@ from unittest.mock import patch -from homeassistant.components.minecraft_server.api import ( - MinecraftServerAddressError, - MinecraftServerType, -) +from mcstatus import BedrockServer, JavaServer + +from homeassistant.components.minecraft_server.api import MinecraftServerType from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import TEST_ADDRESS +from .const import ( + TEST_ADDRESS, + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) USER_INPUT = { CONF_NAME: DEFAULT_NAME, @@ -30,12 +35,14 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_address_validation_failed(hass: HomeAssistant) -> None: +async def test_address_validation_failure(hass: HomeAssistant) -> None: """Test error in case of a failed connection.""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -45,15 +52,17 @@ async def test_address_validation_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_java_connection_failed(hass: HomeAssistant) -> None: +async def test_java_connection_failure(hass: HomeAssistant) -> None: """Test error in case of a failed connection to a Java Edition server.""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[MinecraftServerAddressError, None], - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", - return_value=False, + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + side_effect=OSError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -63,15 +72,14 @@ async def test_java_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_bedrock_connection_failed(hass: HomeAssistant) -> None: +async def test_bedrock_connection_failure(hass: HomeAssistant) -> None: """Test error in case of a failed connection to a Bedrock Edition server.""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[None, MinecraftServerAddressError], - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", - return_value=False, + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + side_effect=OSError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -81,19 +89,17 @@ async def test_bedrock_connection_failed(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} -async def test_java_connection_succeeded(hass: HomeAssistant) -> None: +async def test_java_connection(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Java Edition server.""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[ - MinecraftServerAddressError, # async_step_user (Bedrock Edition) - None, # async_step_user (Java Edition) - None, # async_setup_entry - ], - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", - return_value=True, + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -106,15 +112,14 @@ async def test_java_connection_succeeded(hass: HomeAssistant) -> None: assert result["data"][CONF_TYPE] == MinecraftServerType.JAVA_EDITION -async def test_bedrock_connection_succeeded(hass: HomeAssistant) -> None: +async def test_bedrock_connection(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection to a Bedrock Edition server.""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=None, - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", - return_value=True, + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -130,9 +135,11 @@ async def test_bedrock_connection_succeeded(hass: HomeAssistant) -> None: async def test_recovery(hass: HomeAssistant) -> None: """Test config flow recovery (successful connection after a failed connection).""" with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[MinecraftServerAddressError, MinecraftServerAddressError], - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + side_effect=ValueError, + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + side_effect=ValueError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -141,12 +148,11 @@ async def test_recovery(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "cannot_connect"} with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=None, - return_value=None, + "homeassistant.components.minecraft_server.api.BedrockServer.lookup", + return_value=BedrockServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_is_online", - return_value=True, + "homeassistant.components.minecraft_server.api.BedrockServer.async_status", + return_value=TEST_BEDROCK_STATUS_RESPONSE, ): result2 = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"], user_input=USER_INPUT From f891fb6b4158b4ce2b4e59ace867915e0f656ec7 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 16 Oct 2023 23:13:14 +0200 Subject: [PATCH 491/968] Make location types in co2signal translatable (#102127) --- .../components/co2signal/config_flow.py | 25 +++++++++++++------ .../components/co2signal/strings.json | 9 +++++++ 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 92b09b6e17a..d41bd6e0f78 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -9,15 +9,20 @@ from homeassistant import config_entries from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import CONF_COUNTRY_CODE, DOMAIN from .coordinator import get_data from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name -TYPE_USE_HOME = "Use home location" -TYPE_SPECIFY_COORDINATES = "Specify coordinates" -TYPE_SPECIFY_COUNTRY = "Specify country code" +TYPE_USE_HOME = "use_home_location" +TYPE_SPECIFY_COORDINATES = "specify_coordinates" +TYPE_SPECIFY_COUNTRY = "specify_country_code" class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -32,11 +37,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the initial step.""" data_schema = vol.Schema( { - vol.Required("location", default=TYPE_USE_HOME): vol.In( - ( - TYPE_USE_HOME, - TYPE_SPECIFY_COORDINATES, - TYPE_SPECIFY_COUNTRY, + vol.Required("location"): SelectSelector( + SelectSelectorConfig( + translation_key="location", + mode=SelectSelectorMode.LIST, + options=[ + TYPE_USE_HOME, + TYPE_SPECIFY_COORDINATES, + TYPE_SPECIFY_COUNTRY, + ], ) ), vol.Required(CONF_API_KEY): cv.string, diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 26976decdfc..4564fdf14be 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -50,5 +50,14 @@ } } } + }, + "selector": { + "location": { + "options": { + "use_home_location": "Use home location", + "specify_coordinates": "Specify coordinates", + "specify_country_code": "Specify country code" + } + } } } From 6d457e808f1229c565341200c97aa10a062791eb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 16 Oct 2023 23:26:03 +0200 Subject: [PATCH 492/968] Remove class argument in favor of class variables (zha) (#102117) * Drop id_suffix class argument * Use lowecase attribute --- homeassistant/components/zha/binary_sensor.py | 27 ++-- homeassistant/components/zha/button.py | 14 +- homeassistant/components/zha/entity.py | 19 +-- homeassistant/components/zha/number.py | 153 ++++++++---------- homeassistant/components/zha/select.py | 70 ++++---- homeassistant/components/zha/sensor.py | 90 ++++++----- homeassistant/components/zha/switch.py | 117 +++++++------- 7 files changed, 242 insertions(+), 248 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index c32bd5eeb67..9929b43a439 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -252,29 +252,32 @@ class SinopeLeakStatus(BinarySensor): "_TZE200_htnnfasr", }, ) -class FrostLock(BinarySensor, id_suffix="frost_lock"): +class FrostLock(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "frost_lock" + _unique_id_suffix = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK _attr_name: str = "Frost lock" @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") -class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): +class ReplaceFilter(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "replace_filter" + _unique_id_suffix = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "Replace filter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): +class AqaraPetFeederErrorDetected(BinarySensor): """ZHA aqara pet feeder error detected binary sensor.""" SENSOR_ATTR = "error_detected" + _unique_id_suffix = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_name: str = "Error detected" @@ -283,28 +286,31 @@ class AqaraPetFeederErrorDetected(BinarySensor, id_suffix="error_detected"): cluster_handler_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) -class XiaomiPlugConsumerConnected(BinarySensor, id_suffix="consumer_connected"): +class XiaomiPlugConsumerConnected(BinarySensor): """ZHA Xiaomi plug consumer connected binary sensor.""" SENSOR_ATTR = "consumer_connected" + _unique_id_suffix = "consumer_connected" _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) -class AqaraThermostatWindowOpen(BinarySensor, id_suffix="window_open"): +class AqaraThermostatWindowOpen(BinarySensor): """ZHA Aqara thermostat window open binary sensor.""" SENSOR_ATTR = "window_open" + _unique_id_suffix = "window_open" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW _attr_name: str = "Window open" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) -class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): +class AqaraThermostatValveAlarm(BinarySensor): """ZHA Aqara thermostat valve alarm binary sensor.""" SENSOR_ATTR = "valve_alarm" + _unique_id_suffix = "valve_alarm" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_name: str = "Valve alarm" @@ -312,10 +318,11 @@ class AqaraThermostatValveAlarm(BinarySensor, id_suffix="valve_alarm"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): +class AqaraThermostatCalibrated(BinarySensor): """ZHA Aqara thermostat calibrated binary sensor.""" SENSOR_ATTR = "calibrated" + _unique_id_suffix = "calibrated" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "Calibrated" @@ -323,18 +330,20 @@ class AqaraThermostatCalibrated(BinarySensor, id_suffix="calibrated"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatExternalSensor(BinarySensor, id_suffix="sensor"): +class AqaraThermostatExternalSensor(BinarySensor): """ZHA Aqara thermostat external sensor binary sensor.""" SENSOR_ATTR = "sensor" + _unique_id_suffix = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_name: str = "External sensor" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) -class AqaraLinkageAlarmState(BinarySensor, id_suffix="linkage_alarm_state"): +class AqaraLinkageAlarmState(BinarySensor): """ZHA Aqara linkage alarm state binary sensor.""" SENSOR_ATTR = "linkage_alarm_state" + _unique_id_suffix = "linkage_alarm_state" _attr_name: str = "Linkage alarm state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 4114a3dea7c..35fee436ec5 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -145,9 +145,10 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): "_TZE200_htnnfasr", }, ) -class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): +class FrostLockResetButton(ZHAAttributeButton): """Defines a ZHA frost lock reset button.""" + _unique_id_suffix = "reset_frost_lock" _attribute_name = "frost_lock_reset" _attr_name = "Frost lock reset" _attribute_value = 0 @@ -158,11 +159,10 @@ class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class NoPresenceStatusResetButton( - ZHAAttributeButton, id_suffix="reset_no_presence_status" -): +class NoPresenceStatusResetButton(ZHAAttributeButton): """Defines a ZHA no presence status reset button.""" + _unique_id_suffix = "reset_no_presence_status" _attribute_name = "reset_no_presence_status" _attr_name = "Presence status reset" _attribute_value = 1 @@ -171,9 +171,10 @@ class NoPresenceStatusResetButton( @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) -class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): +class AqaraPetFeederFeedButton(ZHAAttributeButton): """Defines a feed button for the aqara c1 pet feeder.""" + _unique_id_suffix = "feeding" _attribute_name = "feeding" _attr_name = "Feed" _attribute_value = 1 @@ -182,9 +183,10 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton, id_suffix="feeding"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraSelfTestButton(ZHAAttributeButton, id_suffix="self_test"): +class AqaraSelfTestButton(ZHAAttributeButton): """Defines a ZHA self-test button for Aqara smoke sensors.""" + _unique_id_suffix = "self_test" _attribute_name = "self_test" _attr_name = "Self-test" _attribute_value = 1 diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index da34b829907..05e1da7c570 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -46,15 +46,18 @@ DEFAULT_UPDATE_GROUP_FROM_CHILD_DELAY = 0.5 class BaseZhaEntity(LogMixin, entity.Entity): """A base class for ZHA entities.""" - unique_id_suffix: str | None = None + _unique_id_suffix: str | None = None + """suffix to add to the unique_id of the entity. Used for multi + entities using the same cluster handler/cluster id for the entity.""" + _attr_has_entity_name = True _attr_should_poll = False def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None: """Init ZHA entity.""" self._unique_id: str = unique_id - if self.unique_id_suffix: - self._unique_id += f"-{self.unique_id_suffix}" + if self._unique_id_suffix: + self._unique_id += f"-{self._unique_id_suffix}" self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device @@ -144,16 +147,6 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): remove_future: asyncio.Future[Any] - def __init_subclass__(cls, id_suffix: str | None = None, **kwargs: Any) -> None: - """Initialize subclass. - - :param id_suffix: suffix to add to the unique_id of the entity. Used for multi - entities using the same cluster handler/cluster id for the entity. - """ - super().__init_subclass__(**kwargs) - if id_suffix: - cls.unique_id_suffix = id_suffix - def __init__( self, unique_id: str, diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index b6876155312..01cfa763cd5 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -452,11 +452,10 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraMotionDetectionInterval( - ZHANumberConfigurationEntity, id_suffix="detection_interval" -): +class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): """Representation of a ZHA motion detection interval configuration entity.""" + _unique_id_suffix = "detection_interval" _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" @@ -465,11 +464,10 @@ class AqaraMotionDetectionInterval( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnOffTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" -): +class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on off transition time configuration entity.""" + _unique_id_suffix = "on_off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" @@ -478,9 +476,10 @@ class OnOffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): +class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on level configuration entity.""" + _unique_id_suffix = "on_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" @@ -489,11 +488,10 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OnTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="on_transition_time" -): +class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA on transition time configuration entity.""" + _unique_id_suffix = "on_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" @@ -502,11 +500,10 @@ class OnTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class OffTransitionTimeConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="off_transition_time" -): +class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA off transition time configuration entity.""" + _unique_id_suffix = "off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" @@ -515,11 +512,10 @@ class OffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class DefaultMoveRateConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="default_move_rate" -): +class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA default move rate configuration entity.""" + _unique_id_suffix = "default_move_rate" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" @@ -528,11 +524,10 @@ class DefaultMoveRateConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpCurrentLevelConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="start_up_current_level" -): +class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA startup current level configuration entity.""" + _unique_id_suffix = "start_up_current_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" @@ -541,11 +536,10 @@ class StartUpCurrentLevelConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class StartUpColorTemperatureConfigurationEntity( - ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" -): +class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): """Representation of a ZHA startup color temperature configuration entity.""" + _unique_id_suffix = "start_up_color_temperature" _attr_native_min_value: float = 153 _attr_native_max_value: float = 500 _zcl_attribute: str = "start_up_color_temperature" @@ -572,9 +566,10 @@ class StartUpColorTemperatureConfigurationEntity( }, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_duration"): +class TimerDurationMinutes(ZHANumberConfigurationEntity): """Representation of a ZHA timer duration configuration entity.""" + _unique_id_suffix = "timer_duration" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0x00 @@ -586,9 +581,10 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): +class FilterLifeTime(ZHANumberConfigurationEntity): """Representation of a ZHA filter lifetime configuration entity.""" + _unique_id_suffix = "filter_life_time" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0x00 @@ -604,9 +600,10 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") models={"ti.router"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_power"): +class TiRouterTransmitPower(ZHANumberConfigurationEntity): """Representation of a ZHA TI transmit power configuration entity.""" + _unique_id_suffix = "transmit_power" _attr_native_min_value: float = -20 _attr_native_max_value: float = 20 _zcl_attribute: str = "transmit_power" @@ -615,11 +612,10 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingUpSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" -): +class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): """Inovelli remote dimming up speed configuration entity.""" + _unique_id_suffix = "dimming_speed_up_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -630,9 +626,10 @@ class InovelliRemoteDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): +class InovelliButtonDelay(ZHANumberConfigurationEntity): """Inovelli button delay configuration entity.""" + _unique_id_suffix = "dimming_speed_up_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -643,11 +640,10 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingUpSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" -): +class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): """Inovelli local dimming up speed configuration entity.""" + _unique_id_suffix = "dimming_speed_up_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -658,11 +654,10 @@ class InovelliLocalDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOffToOn( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" -): +class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): """Inovelli off to on local ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_off_to_on_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -673,11 +668,10 @@ class InovelliLocalRampRateOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOffToOn( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" -): +class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): """Inovelli off to on remote ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_off_to_on_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -688,11 +682,10 @@ class InovelliRemoteDimmingSpeedOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingDownSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" -): +class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): """Inovelli remote dimming down speed configuration entity.""" + _unique_id_suffix = "dimming_speed_down_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -703,11 +696,10 @@ class InovelliRemoteDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalDimmingDownSpeed( - ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" -): +class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): """Inovelli local dimming down speed configuration entity.""" + _unique_id_suffix = "dimming_speed_down_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -718,11 +710,10 @@ class InovelliLocalDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLocalRampRateOnToOff( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" -): +class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): """Inovelli local on to off ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_on_to_off_local" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -733,11 +724,10 @@ class InovelliLocalRampRateOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliRemoteDimmingSpeedOnToOff( - ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" -): +class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): """Inovelli remote on to off ramp rate configuration entity.""" + _unique_id_suffix = "ramp_rate_on_to_off_remote" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 @@ -748,11 +738,10 @@ class InovelliRemoteDimmingSpeedOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMinimumLoadDimmingLevel( - ZHANumberConfigurationEntity, id_suffix="minimum_level" -): +class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): """Inovelli minimum load dimming level configuration entity.""" + _unique_id_suffix = "minimum_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 1 @@ -763,11 +752,10 @@ class InovelliMinimumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliMaximumLoadDimmingLevel( - ZHANumberConfigurationEntity, id_suffix="maximum_level" -): +class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): """Inovelli maximum load dimming level configuration entity.""" + _unique_id_suffix = "maximum_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 @@ -778,11 +766,10 @@ class InovelliMaximumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliAutoShutoffTimer( - ZHANumberConfigurationEntity, id_suffix="auto_off_timer" -): +class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): """Inovelli automatic switch shutoff timer configuration entity.""" + _unique_id_suffix = "auto_off_timer" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 @@ -793,11 +780,10 @@ class InovelliAutoShutoffTimer( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliLoadLevelIndicatorTimeout( - ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" -): +class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): """Inovelli load level indicator timeout configuration entity.""" + _unique_id_suffix = "load_level_indicator_timeout" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 @@ -808,11 +794,10 @@ class InovelliLoadLevelIndicatorTimeout( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnColor( - ZHANumberConfigurationEntity, id_suffix="led_color_when_on" -): +class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): """Inovelli default all led color when on configuration entity.""" + _unique_id_suffix = "led_color_when_on" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 @@ -823,11 +808,10 @@ class InovelliDefaultAllLEDOnColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffColor( - ZHANumberConfigurationEntity, id_suffix="led_color_when_off" -): +class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): """Inovelli default all led color when off configuration entity.""" + _unique_id_suffix = "led_color_when_off" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 @@ -838,11 +822,10 @@ class InovelliDefaultAllLEDOffColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOnIntensity( - ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" -): +class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): """Inovelli default all led intensity when on configuration entity.""" + _unique_id_suffix = "led_intensity_when_on" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 @@ -853,11 +836,10 @@ class InovelliDefaultAllLEDOnIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDefaultAllLEDOffIntensity( - ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" -): +class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): """Inovelli default all led intensity when off configuration entity.""" + _unique_id_suffix = "led_intensity_when_off" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 @@ -868,11 +850,10 @@ class InovelliDefaultAllLEDOffIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapUpLevel( - ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" -): +class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): """Inovelli double tap up level configuration entity.""" + _unique_id_suffix = "double_tap_up_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 @@ -883,11 +864,10 @@ class InovelliDoubleTapUpLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class InovelliDoubleTapDownLevel( - ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" -): +class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): """Inovelli double tap down level configuration entity.""" + _unique_id_suffix = "double_tap_down_level" _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 @@ -900,9 +880,10 @@ class InovelliDoubleTapDownLevel( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): +class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): """Aqara pet feeder serving size configuration entity.""" + _unique_id_suffix = "serving_size" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 10 @@ -916,11 +897,10 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederPortionWeight( - ZHANumberConfigurationEntity, id_suffix="portion_weight" -): +class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): """Aqara pet feeder portion weight configuration entity.""" + _unique_id_suffix = "portion_weight" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 100 @@ -935,11 +915,10 @@ class AqaraPetFeederPortionWeight( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraThermostatAwayTemp( - ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" -): +class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): """Aqara away preset temperature configuration entity.""" + _unique_id_suffix = "away_preset_temperature" _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 5 _attr_native_max_value: float = 30 diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index fa2e124fd05..6f7563e2e23 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -117,39 +117,37 @@ class ZHANonZCLSelectEntity(ZHAEnumSelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultToneSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.WarningMode.__name__ -): +class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren tone select entity.""" + _unique_id_suffix = IasWd.Warning.WarningMode.__name__ _enum = IasWd.Warning.WarningMode _attr_name = "Default siren tone" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultSirenLevelSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.Warning.SirenLevel.__name__ -): +class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren level select entity.""" + _unique_id_suffix = IasWd.Warning.SirenLevel.__name__ _enum = IasWd.Warning.SirenLevel _attr_name = "Default siren level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeLevelSelectEntity( - ZHANonZCLSelectEntity, id_suffix=IasWd.StrobeLevel.__name__ -): +class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren strobe level select entity.""" + _unique_id_suffix = IasWd.StrobeLevel.__name__ _enum = IasWd.StrobeLevel _attr_name = "Default strobe level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) -class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): +class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): """Representation of a ZHA default siren strobe select entity.""" + _unique_id_suffix = Strobe.__name__ _enum = Strobe _attr_name = "Default strobe" @@ -230,11 +228,10 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) -class ZHAStartupOnOffSelectEntity( - ZCLEnumSelectEntity, id_suffix=OnOff.StartUpOnOff.__name__ -): +class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA startup onoff select entity.""" + _unique_id_suffix = OnOff.StartUpOnOff.__name__ _select_attr = "start_up_on_off" _enum = OnOff.StartUpOnOff _attr_name = "Start-up behavior" @@ -273,9 +270,10 @@ class TuyaPowerOnState(types.enum8): "_TZE200_9mahtqtg", }, ) -class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity, id_suffix="power_on_state"): +class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA power on state select entity.""" + _unique_id_suffix = "power_on_state" _select_attr = "power_on_state" _enum = TuyaPowerOnState _attr_name = "Power on state" @@ -293,9 +291,10 @@ class TuyaBacklightMode(types.enum8): cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F", "TS0121", "TS0001", "TS0002", "TS0003", "TS0004"}, ) -class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): +class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA backlight mode select entity.""" + _unique_id_suffix = "backlight_mode" _select_attr = "backlight_mode" _enum = TuyaBacklightMode _attr_name = "Backlight mode" @@ -331,9 +330,10 @@ class MoesBacklightMode(types.enum8): "_TZE200_9mahtqtg", }, ) -class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity, id_suffix="backlight_mode"): +class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): """Moes devices have a different backlight mode select options.""" + _unique_id_suffix = "backlight_mode" _select_attr = "backlight_mode" _enum = MoesBacklightMode _attr_name = "Backlight mode" @@ -351,9 +351,10 @@ class AqaraMotionSensitivities(types.enum8): cluster_handler_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02", "lumi.motion.agl04"}, ) -class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class AqaraMotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" + _unique_id_suffix = "motion_sensitivity" _select_attr = "motion_sensitivity" _enum = AqaraMotionSensitivities _attr_name = "Motion sensitivity" @@ -372,9 +373,10 @@ class HueV1MotionSensitivities(types.enum8): manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001"}, ) -class HueV1MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class HueV1MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" + _unique_id_suffix = "motion_sensitivity" _select_attr = "sensitivity" _attr_name = "Hue motion sensitivity" _enum = HueV1MotionSensitivities @@ -395,9 +397,10 @@ class HueV2MotionSensitivities(types.enum8): manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML002", "SML003", "SML004"}, ) -class HueV2MotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): +class HueV2MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" + _unique_id_suffix = "motion_sensitivity" _select_attr = "sensitivity" _attr_name = "Hue motion sensitivity" _enum = HueV2MotionSensitivities @@ -413,9 +416,10 @@ class AqaraMonitoringModess(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): +class AqaraMonitoringMode(ZCLEnumSelectEntity): """Representation of a ZHA monitoring mode configuration entity.""" + _unique_id_suffix = "monitoring_mode" _select_attr = "monitoring_mode" _enum = AqaraMonitoringModess _attr_name = "Monitoring mode" @@ -432,9 +436,10 @@ class AqaraApproachDistances(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac01"} ) -class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): +class AqaraApproachDistance(ZCLEnumSelectEntity): """Representation of a ZHA approach distance configuration entity.""" + _unique_id_suffix = "approach_distance" _select_attr = "approach_distance" _enum = AqaraApproachDistances _attr_name = "Approach distance" @@ -450,9 +455,10 @@ class AqaraE1ReverseDirection(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="window_covering", models={"lumi.curtain.agl001"} ) -class AqaraCurtainMode(ZCLEnumSelectEntity, id_suffix="window_covering_mode"): +class AqaraCurtainMode(ZCLEnumSelectEntity): """Representation of a ZHA curtain mode configuration entity.""" + _unique_id_suffix = "window_covering_mode" _select_attr = "window_covering_mode" _enum = AqaraE1ReverseDirection _attr_name = "Curtain mode" @@ -468,9 +474,10 @@ class InovelliOutputMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliOutputModeEntity(ZCLEnumSelectEntity, id_suffix="output_mode"): +class InovelliOutputModeEntity(ZCLEnumSelectEntity): """Inovelli output mode control.""" + _unique_id_suffix = "output_mode" _select_attr = "output_mode" _enum = InovelliOutputMode _attr_name: str = "Output mode" @@ -488,9 +495,10 @@ class InovelliSwitchType(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliSwitchTypeEntity(ZCLEnumSelectEntity, id_suffix="switch_type"): +class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): """Inovelli switch type control.""" + _unique_id_suffix = "switch_type" _select_attr = "switch_type" _enum = InovelliSwitchType _attr_name: str = "Switch type" @@ -506,9 +514,10 @@ class InovelliLedScalingMode(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliLedScalingModeEntity(ZCLEnumSelectEntity, id_suffix="led_scaling_mode"): +class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): """Inovelli led mode control.""" + _unique_id_suffix = "led_scaling_mode" _select_attr = "led_scaling_mode" _enum = InovelliLedScalingMode _attr_name: str = "Led scaling mode" @@ -524,11 +533,10 @@ class InovelliNonNeutralOutput(types.enum1): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliNonNeutralOutputEntity( - ZCLEnumSelectEntity, id_suffix="increased_non_neutral_output" -): +class InovelliNonNeutralOutputEntity(ZCLEnumSelectEntity): """Inovelli non neutral output control.""" + _unique_id_suffix = "increased_non_neutral_output" _select_attr = "increased_non_neutral_output" _enum = InovelliNonNeutralOutput _attr_name: str = "Non neutral output" @@ -544,9 +552,10 @@ class AqaraFeedingMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederMode(ZCLEnumSelectEntity, id_suffix="feeding_mode"): +class AqaraPetFeederMode(ZCLEnumSelectEntity): """Representation of an Aqara pet feeder mode configuration entity.""" + _unique_id_suffix = "feeding_mode" _select_attr = "feeding_mode" _enum = AqaraFeedingMode _attr_name = "Mode" @@ -564,9 +573,10 @@ class AqaraThermostatPresetMode(types.enum8): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatPreset(ZCLEnumSelectEntity, id_suffix="preset"): +class AqaraThermostatPreset(ZCLEnumSelectEntity): """Representation of an Aqara thermostat preset configuration entity.""" + _unique_id_suffix = "preset" _select_attr = "preset" _enum = AqaraThermostatPresetMode _attr_name = "Preset" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b733e5cc3cf..66b422e0d8b 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -319,12 +319,11 @@ class PolledElectricalMeasurement(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementApparentPower( - ElectricalMeasurement, id_suffix="apparent_power" -): +class ElectricalMeasurementApparentPower(ElectricalMeasurement): """Apparent power measurement.""" SENSOR_ATTR = "apparent_power" + _unique_id_suffix = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE @@ -333,10 +332,11 @@ class ElectricalMeasurementApparentPower( @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): +class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): """RMS current measurement.""" SENSOR_ATTR = "rms_current" + _unique_id_suffix = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE @@ -345,10 +345,11 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): +class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): """RMS Voltage measurement.""" SENSOR_ATTR = "rms_voltage" + _unique_id_suffix = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT @@ -357,10 +358,11 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): +class ElectricalMeasurementFrequency(ElectricalMeasurement): """Frequency measurement.""" SENSOR_ATTR = "ac_frequency" + _unique_id_suffix = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_name: str = "AC frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ @@ -369,10 +371,11 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): +class ElectricalMeasurementPowerFactor(ElectricalMeasurement): """Frequency measurement.""" SENSOR_ATTR = "power_factor" + _unique_id_suffix = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE @@ -499,10 +502,11 @@ class SmartEnergyMetering(Sensor): stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): +class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_summ_delivered" + _unique_id_suffix = "summation_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_name: str = "Summation delivered" @@ -558,12 +562,11 @@ class PolledSmartEnergySummation(SmartEnergySummation): models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier1SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier1_summation_delivered" -): +class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier1_summ_delivered" + _unique_id_suffix = "tier1_summation_delivered" _attr_name: str = "Tier 1 summation delivered" @@ -572,12 +575,11 @@ class Tier1SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier2SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier2_summation_delivered" -): +class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier2_summ_delivered" + _unique_id_suffix = "tier2_summation_delivered" _attr_name: str = "Tier 2 summation delivered" @@ -586,12 +588,11 @@ class Tier2SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier3SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier3_summation_delivered" -): +class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier3_summ_delivered" + _unique_id_suffix = "tier3_summation_delivered" _attr_name: str = "Tier 3 summation delivered" @@ -600,12 +601,11 @@ class Tier3SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier4SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier4_summation_delivered" -): +class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier4_summ_delivered" + _unique_id_suffix = "tier4_summation_delivered" _attr_name: str = "Tier 4 summation delivered" @@ -614,12 +614,11 @@ class Tier4SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier5SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier5_summation_delivered" -): +class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier5_summ_delivered" + _unique_id_suffix = "tier5_summation_delivered" _attr_name: str = "Tier 5 summation delivered" @@ -628,12 +627,11 @@ class Tier5SmartEnergySummation( models={"ZLinky_TIC"}, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class Tier6SmartEnergySummation( - PolledSmartEnergySummation, id_suffix="tier6_summation_delivered" -): +class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" SENSOR_ATTR: int | str = "current_tier6_summ_delivered" + _unique_id_suffix = "tier6_summation_delivered" _attr_name: str = "Tier 6 summation delivered" @@ -772,9 +770,10 @@ class FormaldehydeConcentration(Sensor): stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): +class ThermostatHVACAction(Sensor): """Thermostat HVAC action sensor.""" + _unique_id_suffix = "hvac_action" _attr_name: str = "HVAC action" @classmethod @@ -891,9 +890,11 @@ class SinopeHVACAction(ThermostatHVACAction): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class RSSISensor(Sensor, id_suffix="rssi"): +class RSSISensor(Sensor): """RSSI sensor for a device.""" + SENSOR_ATTR = "rssi" + _unique_id_suffix = "rssi" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.SIGNAL_STRENGTH _attr_native_unit_of_measurement: str | None = SIGNAL_STRENGTH_DECIBELS_MILLIWATT @@ -901,7 +902,6 @@ class RSSISensor(Sensor, id_suffix="rssi"): _attr_entity_registry_enabled_default = False _attr_should_poll = True # BaseZhaEntity defaults to False _attr_name: str = "RSSI" - unique_id_suffix: str @classmethod def create_entity( @@ -915,7 +915,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): Return entity if it is a supported configuration, otherwise return None """ - key = f"{CLUSTER_HANDLER_BASIC}_{cls.unique_id_suffix}" + key = f"{CLUSTER_HANDLER_BASIC}_{cls._unique_id_suffix}" if ZHA_ENTITIES.prevent_entity_creation(Platform.SENSOR, zha_device.ieee, key): return None return cls(unique_id, zha_device, cluster_handlers, **kwargs) @@ -923,14 +923,16 @@ class RSSISensor(Sensor, id_suffix="rssi"): @property def native_value(self) -> StateType: """Return the state of the entity.""" - return getattr(self._zha_device.device, self.unique_id_suffix) + return getattr(self._zha_device.device, self.SENSOR_ATTR) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class LQISensor(RSSISensor, id_suffix="lqi"): +class LQISensor(RSSISensor): """LQI sensor for a device.""" + SENSOR_ATTR = "lqi" + _unique_id_suffix = "lqi" _attr_name: str = "LQI" _attr_device_class = None _attr_native_unit_of_measurement = None @@ -943,10 +945,11 @@ class LQISensor(RSSISensor, id_suffix="lqi"): }, ) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class TimeLeft(Sensor, id_suffix="time_left"): +class TimeLeft(Sensor): """Sensor that displays time left value.""" SENSOR_ATTR = "timer_time_left" + _unique_id_suffix = "time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _attr_name: str = "Time left" @@ -955,10 +958,11 @@ class TimeLeft(Sensor, id_suffix="time_left"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): +class IkeaDeviceRunTime(Sensor): """Sensor that displays device run time (in minutes).""" SENSOR_ATTR = "device_run_time" + _unique_id_suffix = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _attr_name: str = "Device run time" @@ -968,10 +972,11 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") # pylint: disable-next=hass-invalid-inheritance # needs fixing -class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): +class IkeaFilterRunTime(Sensor): """Sensor that displays run time of the current filter (in minutes).""" SENSOR_ATTR = "filter_run_time" + _unique_id_suffix = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _attr_name: str = "Filter run time" @@ -988,10 +993,11 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): +class AqaraPetFeederLastFeedingSource(Sensor): """Sensor that displays the last feeding source of pet feeder.""" SENSOR_ATTR = "last_feeding_source" + _unique_id_suffix = "last_feeding_source" _attr_name: str = "Last feeding source" _attr_icon = "mdi:devices" @@ -1002,20 +1008,22 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): +class AqaraPetFeederLastFeedingSize(Sensor): """Sensor that displays the last feeding size of the pet feeder.""" SENSOR_ATTR = "last_feeding_size" + _unique_id_suffix = "last_feeding_size" _attr_name: str = "Last feeding size" _attr_icon: str = "mdi:counter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): +class AqaraPetFeederPortionsDispensed(Sensor): """Sensor that displays the number of portions dispensed by the pet feeder.""" SENSOR_ATTR = "portions_dispensed" + _unique_id_suffix = "portions_dispensed" _attr_name: str = "Portions dispensed today" _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:counter" @@ -1023,10 +1031,11 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): +class AqaraPetFeederWeightDispensed(Sensor): """Sensor that displays the weight dispensed by the pet feeder.""" SENSOR_ATTR = "weight_dispensed" + _unique_id_suffix = "weight_dispensed" _attr_name: str = "Weight dispensed today" _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING @@ -1035,10 +1044,11 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) # pylint: disable-next=hass-invalid-inheritance # needs fixing -class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): +class AqaraSmokeDensityDbm(Sensor): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" SENSOR_ATTR = "smoke_density_dbm" + _unique_id_suffix = "smoke_density_dbm" _attr_name: str = "Smoke density" _attr_native_unit_of_measurement = "dB/m" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index eff8f727c1c..6224eb02598 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -270,11 +270,10 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): "_TZE200_b6wax7g0", }, ) -class OnOffWindowDetectionFunctionConfigurationEntity( - ZHASwitchConfigurationEntity, id_suffix="on_off_window_opened_detection" -): +class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEntity): """Representation of a ZHA window detection configuration entity.""" + _unique_id_suffix = "on_off_window_opened_detection" _zcl_attribute: str = "window_detection_function" _zcl_inverter_attribute: str = "window_detection_function_inverter" _attr_name: str = "Invert window detection" @@ -283,11 +282,10 @@ class OnOffWindowDetectionFunctionConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.motion.ac02"} ) -class P1MotionTriggerIndicatorSwitch( - ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" -): +class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" + _unique_id_suffix = "trigger_indicator" _zcl_attribute: str = "trigger_indicator" _attr_name = "LED trigger indicator" @@ -296,11 +294,10 @@ class P1MotionTriggerIndicatorSwitch( cluster_handler_names="opple_cluster", models={"lumi.plug.mmeu01", "lumi.plug.maeu01"}, ) -class XiaomiPlugPowerOutageMemorySwitch( - ZHASwitchConfigurationEntity, id_suffix="power_outage_memory" -): +class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA power outage memory configuration entity.""" + _unique_id_suffix = "power_outage_memory" _zcl_attribute: str = "power_outage_memory" _attr_name = "Power outage memory" @@ -310,11 +307,10 @@ class XiaomiPlugPowerOutageMemorySwitch( manufacturers={"Philips", "Signify Netherlands B.V."}, models={"SML001", "SML002", "SML003", "SML004"}, ) -class HueMotionTriggerIndicatorSwitch( - ZHASwitchConfigurationEntity, id_suffix="trigger_indicator" -): +class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" + _unique_id_suffix = "trigger_indicator" _zcl_attribute: str = "trigger_indicator" _attr_name = "LED trigger indicator" @@ -323,9 +319,10 @@ class HueMotionTriggerIndicatorSwitch( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class ChildLock(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" + _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" _attr_name = "Child lock" @@ -334,9 +331,10 @@ class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): +class DisableLed(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" + _unique_id_suffix = "disable_led" _zcl_attribute: str = "disable_led" _attr_name = "Disable LED" @@ -344,9 +342,10 @@ class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switch"): +class InovelliInvertSwitch(ZHASwitchConfigurationEntity): """Inovelli invert switch control.""" + _unique_id_suffix = "invert_switch" _zcl_attribute: str = "invert_switch" _attr_name: str = "Invert switch" @@ -354,9 +353,10 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity, id_suffix="invert_switc @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_mode"): +class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): """Inovelli smart bulb mode control.""" + _unique_id_suffix = "smart_bulb_mode" _zcl_attribute: str = "smart_bulb_mode" _attr_name: str = "Smart bulb mode" @@ -364,11 +364,10 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity, id_suffix="smart_bulb_ @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapUpEnabled( - ZHASwitchConfigurationEntity, id_suffix="double_tap_up_enabled" -): +class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap up enabled.""" + _unique_id_suffix = "double_tap_up_enabled" _zcl_attribute: str = "double_tap_up_enabled" _attr_name: str = "Double tap up enabled" @@ -376,11 +375,10 @@ class InovelliDoubleTapUpEnabled( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDoubleTapDownEnabled( - ZHASwitchConfigurationEntity, id_suffix="double_tap_down_enabled" -): +class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap down enabled.""" + _unique_id_suffix = "double_tap_down_enabled" _zcl_attribute: str = "double_tap_down_enabled" _attr_name: str = "Double tap down enabled" @@ -388,11 +386,10 @@ class InovelliDoubleTapDownEnabled( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliAuxSwitchScenes( - ZHASwitchConfigurationEntity, id_suffix="aux_switch_scenes" -): +class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): """Inovelli unique aux switch scenes.""" + _unique_id_suffix = "aux_switch_scenes" _zcl_attribute: str = "aux_switch_scenes" _attr_name: str = "Aux switch scenes" @@ -400,11 +397,10 @@ class InovelliAuxSwitchScenes( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliBindingOffToOnSyncLevel( - ZHASwitchConfigurationEntity, id_suffix="binding_off_to_on_sync_level" -): +class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): """Inovelli send move to level with on/off to bound devices.""" + _unique_id_suffix = "binding_off_to_on_sync_level" _zcl_attribute: str = "binding_off_to_on_sync_level" _attr_name: str = "Binding off to on sync level" @@ -412,11 +408,10 @@ class InovelliBindingOffToOnSyncLevel( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliLocalProtection( - ZHASwitchConfigurationEntity, id_suffix="local_protection" -): +class InovelliLocalProtection(ZHASwitchConfigurationEntity): """Inovelli local protection control.""" + _unique_id_suffix = "local_protection" _zcl_attribute: str = "local_protection" _attr_name: str = "Local protection" @@ -424,9 +419,10 @@ class InovelliLocalProtection( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_mode"): +class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): """Inovelli only 1 LED mode control.""" + _unique_id_suffix = "on_off_led_mode" _zcl_attribute: str = "on_off_led_mode" _attr_name: str = "Only 1 LED mode" @@ -434,11 +430,10 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity, id_suffix="on_off_led_m @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliFirmwareProgressLED( - ZHASwitchConfigurationEntity, id_suffix="firmware_progress_led" -): +class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): """Inovelli firmware progress LED control.""" + _unique_id_suffix = "firmware_progress_led" _zcl_attribute: str = "firmware_progress_led" _attr_name: str = "Firmware progress LED" @@ -446,11 +441,10 @@ class InovelliFirmwareProgressLED( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliRelayClickInOnOffMode( - ZHASwitchConfigurationEntity, id_suffix="relay_click_in_on_off_mode" -): +class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): """Inovelli relay click in on off mode control.""" + _unique_id_suffix = "relay_click_in_on_off_mode" _zcl_attribute: str = "relay_click_in_on_off_mode" _attr_name: str = "Disable relay click in on off mode" @@ -458,11 +452,10 @@ class InovelliRelayClickInOnOffMode( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names=CLUSTER_HANDLER_INOVELLI, ) -class InovelliDisableDoubleTapClearNotificationsMode( - ZHASwitchConfigurationEntity, id_suffix="disable_clear_notifications_double_tap" -): +class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntity): """Inovelli disable clear notifications double tap control.""" + _unique_id_suffix = "disable_clear_notifications_double_tap" _zcl_attribute: str = "disable_clear_notifications_double_tap" _attr_name: str = "Disable config 2x tap to clear notifications" @@ -470,11 +463,10 @@ class InovelliDisableDoubleTapClearNotificationsMode( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederLEDIndicator( - ZHASwitchConfigurationEntity, id_suffix="disable_led_indicator" -): +class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): """Representation of a LED indicator configuration entity.""" + _unique_id_suffix = "disable_led_indicator" _zcl_attribute: str = "disable_led_indicator" _attr_name = "LED indicator" _force_inverted = True @@ -484,9 +476,10 @@ class AqaraPetFeederLEDIndicator( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) -class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" + _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" @@ -496,9 +489,10 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity, id_suffix="child_loc cluster_handler_names=CLUSTER_HANDLER_ON_OFF, models={"TS011F"}, ) -class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" + _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" @@ -507,11 +501,10 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity, id_suffix="child_lock"): @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatWindowDetection( - ZHASwitchConfigurationEntity, id_suffix="window_detection" -): +class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat window detection configuration entity.""" + _unique_id_suffix = "window_detection" _zcl_attribute: str = "window_detection" _attr_name = "Window detection" @@ -519,11 +512,10 @@ class AqaraThermostatWindowDetection( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatValveDetection( - ZHASwitchConfigurationEntity, id_suffix="valve_detection" -): +class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat valve detection configuration entity.""" + _unique_id_suffix = "valve_detection" _zcl_attribute: str = "valve_detection" _attr_name = "Valve detection" @@ -531,9 +523,10 @@ class AqaraThermostatValveDetection( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) -class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): +class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat child lock configuration entity.""" + _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" _attr_name = "Child lock" _attr_icon: str = "mdi:account-lock" @@ -542,11 +535,10 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lo @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraHeartbeatIndicator( - ZHASwitchConfigurationEntity, id_suffix="heartbeat_indicator" -): +class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" + _unique_id_suffix = "heartbeat_indicator" _zcl_attribute: str = "heartbeat_indicator" _attr_name = "Heartbeat indicator" _attr_icon: str = "mdi:heart-flash" @@ -555,9 +547,10 @@ class AqaraHeartbeatIndicator( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm"): +class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" + _unique_id_suffix = "linkage_alarm" _zcl_attribute: str = "linkage_alarm" _attr_name = "Linkage alarm" _attr_icon: str = "mdi:shield-link-variant" @@ -566,11 +559,10 @@ class AqaraLinkageAlarm(ZHASwitchConfigurationEntity, id_suffix="linkage_alarm") @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualMute( - ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_mute" -): +class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + _unique_id_suffix = "buzzer_manual_mute" _zcl_attribute: str = "buzzer_manual_mute" _attr_name = "Buzzer manual mute" _attr_icon: str = "mdi:volume-off" @@ -579,11 +571,10 @@ class AqaraBuzzerManualMute( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"} ) -class AqaraBuzzerManualAlarm( - ZHASwitchConfigurationEntity, id_suffix="buzzer_manual_alarm" -): +class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" + _unique_id_suffix = "buzzer_manual_alarm" _zcl_attribute: str = "buzzer_manual_alarm" _attr_name = "Buzzer manual alarm" _attr_icon: str = "mdi:bullhorn" From 25671a2e42781c445cfdd79dcb4fc1a09529667c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 16 Oct 2023 19:13:26 -0500 Subject: [PATCH 493/968] Add HassNevermind intent (bump intents package) (#102131) * Add HassNevermind intent * Bump intents package to 2023.10.16 --- .../components/conversation/manifest.json | 2 +- homeassistant/components/intent/__init__.py | 14 ++++++++++++++ homeassistant/helpers/intent.py | 1 + homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/conversation/test_default_agent.py | 10 ++++++++++ 7 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index f11dda15a4e..1b4d346082a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.2"] + "requirements": ["hassil==1.2.5", "home-assistant-intents==2023.10.16"] } diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index b2c77fed3af..5d3a2259bed 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -56,6 +56,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, GetStateIntentHandler(), ) + intent.async_register( + hass, + NevermindIntentHandler(), + ) return True @@ -206,6 +210,16 @@ class GetStateIntentHandler(intent.IntentHandler): return response +class NevermindIntentHandler(intent.IntentHandler): + """Takes no action.""" + + intent_type = intent.INTENT_NEVERMIND + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Doe not do anything, and produces an empty response.""" + return intent_obj.create_response() + + async def _async_process_intent(hass: HomeAssistant, domain: str, platform): """Process the intents of an integration.""" await platform.async_setup_intents(hass) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index f27cea8dd1e..056f972e7f7 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -31,6 +31,7 @@ INTENT_TURN_OFF = "HassTurnOff" INTENT_TURN_ON = "HassTurnOn" INTENT_TOGGLE = "HassToggle" INTENT_GET_STATE = "HassGetState" +INTENT_NEVERMIND = "HassNevermind" SLOT_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index db9e6fd9913..e1ec8425630 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,7 +23,7 @@ hass-nabucasa==0.73.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20231005.0 -home-assistant-intents==2023.10.2 +home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 janus==1.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 64b489d7f4c..f9149efab11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1009,7 +1009,7 @@ holidays==0.28 home-assistant-frontend==20231005.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.2 +home-assistant-intents==2023.10.16 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf1dbf4f2cb..19eab9c0488 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -798,7 +798,7 @@ holidays==0.28 home-assistant-frontend==20231005.0 # homeassistant.components.conversation -home-assistant-intents==2023.10.2 +home-assistant-intents==2023.10.16 # homeassistant.components.home_connect homeconnect==0.7.2 diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 1677b254ff6..c75c96ca59b 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -283,3 +283,13 @@ async def test_shopping_list_add_item( assert result.response.speech == { "plain": {"speech": "Added apples", "extra_data": None} } + + +async def test_nevermind_item(hass: HomeAssistant, init_components) -> None: + """Test HassNevermind intent through the default agent.""" + result = await conversation.async_converse(hass, "nevermind", None, Context()) + assert result.response.intent is not None + assert result.response.intent.intent_type == intent.INTENT_NEVERMIND + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert not result.response.speech From 912032e8b92b40a004a5311f4a461faa0cdb9678 Mon Sep 17 00:00:00 2001 From: Brian Lalor Date: Tue, 17 Oct 2023 00:18:03 -0400 Subject: [PATCH 494/968] Add support for Govee H5055 (#100365) Co-authored-by: J. Nick Koston --- homeassistant/components/govee_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/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index b8fea6a07e1..5c47f116ce5 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -85,5 +85,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.23.0"] + "requirements": ["govee-ble==0.24.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9149efab11..016f2723be6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -918,7 +918,7 @@ googlemaps==2.5.1 goslide-api==0.5.1 # homeassistant.components.govee_ble -govee-ble==0.23.0 +govee-ble==0.24.0 # homeassistant.components.remote_rpi_gpio gpiozero==1.6.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 19eab9c0488..e6b1a556fa9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -731,7 +731,7 @@ google-nest-sdm==3.0.2 googlemaps==2.5.1 # homeassistant.components.govee_ble -govee-ble==0.23.0 +govee-ble==0.24.0 # homeassistant.components.gree greeclimate==1.4.1 From 16c5a12c8792dce898ec2cf0b5cd8d045fce4846 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 17 Oct 2023 19:46:25 +1300 Subject: [PATCH 495/968] Send events for tts stream start/end (#102139) --- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/esphome/voice_assistant.py | 9 +++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f203f8323bd..d06cf1e00d3 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.3", + "aioesphomeapi==18.0.4", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index 8fba4bfb39a..de6313f45aa 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -55,6 +55,8 @@ _VOICE_ASSISTANT_EVENT_TYPES: EsphomeEnumMapper[ VoiceAssistantEventType.VOICE_ASSISTANT_TTS_END: PipelineEventType.TTS_END, VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_START: PipelineEventType.WAKE_WORD_START, VoiceAssistantEventType.VOICE_ASSISTANT_WAKE_WORD_END: PipelineEventType.WAKE_WORD_END, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_START: PipelineEventType.STT_VAD_START, + VoiceAssistantEventType.VOICE_ASSISTANT_STT_VAD_END: PipelineEventType.STT_VAD_END, } ) @@ -296,6 +298,10 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): if self.transport is None: return + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_START, {} + ) + _extension, audio_bytes = await tts.async_get_media_source_audio( self.hass, media_id, @@ -321,4 +327,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): sample_offset += samples_in_chunk finally: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_TTS_STREAM_END, {} + ) self._tts_done.set() diff --git a/requirements_all.txt b/requirements_all.txt index 016f2723be6..2cf921d42cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.3 +aioesphomeapi==18.0.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6b1a556fa9..1a0fb22753b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.3 +aioesphomeapi==18.0.4 # homeassistant.components.flo aioflo==2021.11.0 From eea9de063b191478d25d5c1cd658706c7e9618e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Oct 2023 21:23:30 -1000 Subject: [PATCH 496/968] Replace any expression in HomeKitWindowCover with a simple or (#102146) --- homeassistant/components/homekit_controller/cover.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 0f4af988c14..f94e1145627 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -154,14 +154,9 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): if self.service.has(CharacteristicsTypes.POSITION_HOLD): features |= CoverEntityFeature.STOP - supports_tilt = any( - ( - self.service.has(CharacteristicsTypes.VERTICAL_TILT_CURRENT), - self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT), - ) - ) - - if supports_tilt: + if self.service.has( + CharacteristicsTypes.VERTICAL_TILT_CURRENT + ) or self.service.has(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT): features |= ( CoverEntityFeature.OPEN_TILT | CoverEntityFeature.CLOSE_TILT From 433c022687d4aa84dfe370bf15ef3dff472601bd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Oct 2023 21:26:19 -1000 Subject: [PATCH 497/968] Save previous unique id in entity registry when it changes (#102093) --- homeassistant/helpers/entity_registry.py | 11 +- .../snapshots/test_binary_sensor.ambr | 3 + .../snapshots/test_climate.ambr | 1 + .../snapshots/test_cover.ambr | 1 + .../snapshots/test_light.ambr | 2 + .../snapshots/test_sensor.ambr | 4 + .../snapshots/test_siren.ambr | 3 + .../snapshots/test_switch.ambr | 1 + .../snapshots/test_binary_sensor.ambr | 1 + .../snapshots/test_button.ambr | 4 + .../snapshots/test_image.ambr | 1 + .../snapshots/test_sensor.ambr | 3 + .../snapshots/test_switch.ambr | 2 + .../snapshots/test_update.ambr | 1 + .../elgato/snapshots/test_button.ambr | 2 + .../elgato/snapshots/test_light.ambr | 3 + .../elgato/snapshots/test_sensor.ambr | 5 + .../elgato/snapshots/test_switch.ambr | 2 + .../energyzero/snapshots/test_sensor.ambr | 6 + .../gree/snapshots/test_climate.ambr | 1 + .../gree/snapshots/test_switch.ambr | 5 + .../snapshots/test_init.ambr | 265 ++++++++++++++++++ .../onewire/snapshots/test_binary_sensor.ambr | 16 ++ .../onewire/snapshots/test_sensor.ambr | 41 +++ .../onewire/snapshots/test_switch.ambr | 36 +++ .../renault/snapshots/test_binary_sensor.ambr | 52 ++++ .../renault/snapshots/test_button.ambr | 20 ++ .../snapshots/test_device_tracker.ambr | 6 + .../renault/snapshots/test_select.ambr | 6 + .../renault/snapshots/test_sensor.ambr | 104 +++++++ .../samsungtv/snapshots/test_init.ambr | 1 + .../sfr_box/snapshots/test_binary_sensor.ambr | 4 + .../sfr_box/snapshots/test_button.ambr | 1 + .../sfr_box/snapshots/test_sensor.ambr | 15 + .../tplink_omada/snapshots/test_switch.ambr | 8 + .../twentemilieu/snapshots/test_calendar.ambr | 1 + .../twentemilieu/snapshots/test_sensor.ambr | 5 + .../uptime/snapshots/test_sensor.ambr | 1 + .../components/vesync/snapshots/test_fan.ambr | 4 + .../vesync/snapshots/test_light.ambr | 3 + .../vesync/snapshots/test_sensor.ambr | 15 + .../vesync/snapshots/test_switch.ambr | 2 + .../whois/snapshots/test_sensor.ambr | 10 + .../wled/snapshots/test_binary_sensor.ambr | 1 + .../wled/snapshots/test_button.ambr | 1 + .../wled/snapshots/test_number.ambr | 2 + .../wled/snapshots/test_select.ambr | 4 + .../wled/snapshots/test_switch.ambr | 4 + tests/helpers/test_entity_registry.py | 1 + 49 files changed, 690 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index a5e27280a5b..a97e283af07 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -65,7 +65,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 11 +STORAGE_VERSION_MINOR = 12 STORAGE_KEY = "core.entity_registry" CLEANUP_INTERVAL = 3600 * 24 @@ -156,6 +156,7 @@ class RegistryEntry: entity_id: str = attr.ib() unique_id: str = attr.ib() platform: str = attr.ib() + previous_unique_id: str | None = attr.ib(default=None) aliases: set[str] = attr.ib(factory=set) area_id: str | None = attr.ib(default=None) capabilities: Mapping[str, Any] | None = attr.ib(default=None) @@ -422,6 +423,11 @@ class EntityRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # Version 1.11 adds deleted_entities data["deleted_entities"] = data.get("deleted_entities", []) + if old_major_version == 1 and old_minor_version < 12: + # Version 1.12 adds previous_unique_id + for entity in data["entities"]: + entity["previous_unique_id"] = None + if old_major_version > 1: raise NotImplementedError return data @@ -905,6 +911,7 @@ class EntityRegistry: ) new_values["unique_id"] = new_unique_id old_values["unique_id"] = old.unique_id + new_values["previous_unique_id"] = old.unique_id if not new_values: return old @@ -1072,6 +1079,7 @@ class EntityRegistry: supported_features=entity["supported_features"], translation_key=entity["translation_key"], unique_id=entity["unique_id"], + previous_unique_id=entity["previous_unique_id"], unit_of_measurement=entity["unit_of_measurement"], ) for entity in data["deleted_entities"]: @@ -1127,6 +1135,7 @@ class EntityRegistry: "supported_features": entry.supported_features, "translation_key": entry.translation_key, "unique_id": entry.unique_id, + "previous_unique_id": entry.previous_unique_id, "unit_of_measurement": entry.unit_of_measurement, } for entry in self.entities.values() diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr index 0c86cc94321..58cfc407a77 100644 --- a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Door', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Test', @@ -79,6 +80,7 @@ 'original_icon': None, 'original_name': 'Overload', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Overload', @@ -121,6 +123,7 @@ 'original_icon': None, 'original_name': 'Button 1', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'Test_1', diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr index e0066a10656..0e7c5ba547e 100644 --- a/tests/components/devolo_home_control/snapshots/test_climate.ambr +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -51,6 +51,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'Test', diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr index b2872d0c912..69d1eea4275 100644 --- a/tests/components/devolo_home_control/snapshots/test_cover.ambr +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -38,6 +38,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.Blinds', diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr index 81c1e9b4293..cc02e0a680b 100644 --- a/tests/components/devolo_home_control/snapshots/test_light.ambr +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -45,6 +45,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', @@ -97,6 +98,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Dimmer:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr index cb97ce77af0..0b7edcbd3ea 100644 --- a/tests/components/devolo_home_control/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -40,6 +40,7 @@ 'original_icon': None, 'original_name': 'Battery level', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BatterySensor:Test', @@ -87,6 +88,7 @@ 'original_icon': None, 'original_name': 'Current consumption', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_current', @@ -134,6 +136,7 @@ 'original_icon': None, 'original_name': 'Total consumption', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.Meter:Test_total', @@ -181,6 +184,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.MultiLevelSensor:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr index df1d514a11d..f699090c8cf 100644 --- a/tests/components/devolo_home_control/snapshots/test_siren.ambr +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -43,6 +43,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -93,6 +94,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', @@ -143,6 +145,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr index 4aa95944be0..fffe89337e7 100644 --- a/tests/components/devolo_home_control/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -35,6 +35,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'devolo_home_control', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'devolo.BinarySwitch:Test', diff --git a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr index f247f2dc1f0..985fc64146f 100644 --- a/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_binary_sensor.ambr @@ -37,6 +37,7 @@ 'original_icon': 'mdi:router-network', 'original_name': 'Connected to router', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_to_router', 'unique_id': '1234567890_connected_to_router', diff --git a/tests/components/devolo_home_network/snapshots/test_button.ambr b/tests/components/devolo_home_network/snapshots/test_button.ambr index a124ef57693..f00bb345aeb 100644 --- a/tests/components/devolo_home_network/snapshots/test_button.ambr +++ b/tests/components/devolo_home_network/snapshots/test_button.ambr @@ -79,6 +79,7 @@ 'original_icon': 'mdi:led-on', 'original_name': 'Identify device with a blinking LED', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'identify', 'unique_id': '1234567890_identify', @@ -165,6 +166,7 @@ 'original_icon': None, 'original_name': 'Restart device', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'restart', 'unique_id': '1234567890_restart', @@ -251,6 +253,7 @@ 'original_icon': 'mdi:plus-network-outline', 'original_name': 'Start PLC pairing', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pairing', 'unique_id': '1234567890_pairing', @@ -337,6 +340,7 @@ 'original_icon': 'mdi:wifi-plus', 'original_name': 'Start WPS', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_wps', 'unique_id': '1234567890_start_wps', diff --git a/tests/components/devolo_home_network/snapshots/test_image.ambr b/tests/components/devolo_home_network/snapshots/test_image.ambr index b00f73ca116..e6ca9e4fad5 100644 --- a/tests/components/devolo_home_network/snapshots/test_image.ambr +++ b/tests/components/devolo_home_network/snapshots/test_image.ambr @@ -23,6 +23,7 @@ 'original_icon': None, 'original_name': 'Guest Wifi credentials as QR code', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'image_guest_wifi', 'unique_id': '1234567890_image_guest_wifi', diff --git a/tests/components/devolo_home_network/snapshots/test_sensor.ambr b/tests/components/devolo_home_network/snapshots/test_sensor.ambr index 241313965c4..88eb46d57e8 100644 --- a/tests/components/devolo_home_network/snapshots/test_sensor.ambr +++ b/tests/components/devolo_home_network/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:lan', 'original_name': 'Connected PLC devices', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_plc_devices', 'unique_id': '1234567890_connected_plc_devices', @@ -82,6 +83,7 @@ 'original_icon': 'mdi:wifi', 'original_name': 'Connected Wifi clients', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'connected_wifi_clients', 'unique_id': '1234567890_connected_wifi_clients', @@ -125,6 +127,7 @@ 'original_icon': 'mdi:wifi-marker', 'original_name': 'Neighboring Wifi networks', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'neighboring_wifi_networks', 'unique_id': '1234567890_neighboring_wifi_networks', diff --git a/tests/components/devolo_home_network/snapshots/test_switch.ambr b/tests/components/devolo_home_network/snapshots/test_switch.ambr index 600c9478035..4d268b21317 100644 --- a/tests/components/devolo_home_network/snapshots/test_switch.ambr +++ b/tests/components/devolo_home_network/snapshots/test_switch.ambr @@ -122,6 +122,7 @@ 'original_icon': 'mdi:wifi', 'original_name': 'Enable guest Wifi', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_guest_wifi', 'unique_id': '1234567890_switch_guest_wifi', @@ -165,6 +166,7 @@ 'original_icon': 'mdi:led-off', 'original_name': 'Enable LEDs', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'switch_leds', 'unique_id': '1234567890_switch_leds', diff --git a/tests/components/devolo_home_network/snapshots/test_update.ambr b/tests/components/devolo_home_network/snapshots/test_update.ambr index e9872f5e1b5..6dfba2de9c1 100644 --- a/tests/components/devolo_home_network/snapshots/test_update.ambr +++ b/tests/components/devolo_home_network/snapshots/test_update.ambr @@ -46,6 +46,7 @@ 'original_icon': None, 'original_name': 'Firmware', 'platform': 'devolo_home_network', + 'previous_unique_id': None, 'supported_features': , 'translation_key': 'regular_firmware', 'unique_id': '1234567890_regular_firmware', diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 920aa40bfe7..ed29c443243 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Identify', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_identify', @@ -110,6 +111,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_restart', diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 31f5dfba217..72ae1d7e9b8 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -68,6 +68,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -176,6 +177,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', @@ -282,6 +284,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'CN11A1A00001', diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 5fa7a6e827a..86a4c2e5cc5 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -43,6 +43,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'GW24L1A02987_battery', @@ -127,6 +128,7 @@ 'original_icon': None, 'original_name': 'Battery voltage', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage', 'unique_id': 'GW24L1A02987_voltage', @@ -211,6 +213,7 @@ 'original_icon': None, 'original_name': 'Charging current', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'input_charge_current', 'unique_id': 'GW24L1A02987_input_charge_current', @@ -292,6 +295,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_power', 'unique_id': 'GW24L1A02987_charge_power', @@ -376,6 +380,7 @@ 'original_icon': None, 'original_name': 'Charging voltage', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'input_charge_voltage', 'unique_id': 'GW24L1A02987_input_charge_voltage', diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index dcba00c0a9e..cc841b338c7 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:leaf', 'original_name': 'Energy saving', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_saving', 'unique_id': 'GW24L1A02987_energy_saving', @@ -110,6 +111,7 @@ 'original_icon': 'mdi:battery-off-outline', 'original_name': 'Studio mode', 'platform': 'elgato', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'bypass', 'unique_id': 'GW24L1A02987_bypass', diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index e51aef980d1..f3b5e66ed6c 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -494,6 +494,7 @@ 'original_icon': None, 'original_name': 'Average - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'average_price', 'unit_of_measurement': '€/kWh', @@ -561,6 +562,7 @@ 'original_icon': None, 'original_name': 'Current hour', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/kWh', @@ -625,6 +627,7 @@ 'original_icon': None, 'original_name': 'Time of highest price - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'highest_price_time', 'unit_of_measurement': None, @@ -690,6 +693,7 @@ 'original_icon': 'mdi:clock', 'original_name': 'Hours priced equal or lower than current - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hours_priced_equal_or_lower', 'unit_of_measurement': , @@ -754,6 +758,7 @@ 'original_icon': None, 'original_name': 'Highest price - today', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'max_price', 'unit_of_measurement': '€/kWh', @@ -821,6 +826,7 @@ 'original_icon': None, 'original_name': 'Current hour', 'platform': 'energyzero', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_hour_price', 'unit_of_measurement': '€/m³', diff --git a/tests/components/gree/snapshots/test_climate.ambr b/tests/components/gree/snapshots/test_climate.ambr index f1479cad3d3..568b98daec1 100644 --- a/tests/components/gree/snapshots/test_climate.ambr +++ b/tests/components/gree/snapshots/test_climate.ambr @@ -109,6 +109,7 @@ 'original_icon': None, 'original_name': 'fake-device-1', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'aabbcc112233', diff --git a/tests/components/gree/snapshots/test_switch.ambr b/tests/components/gree/snapshots/test_switch.ambr index 73056fcc465..d2b0a5fbf4e 100644 --- a/tests/components/gree/snapshots/test_switch.ambr +++ b/tests/components/gree/snapshots/test_switch.ambr @@ -85,6 +85,7 @@ 'original_icon': 'mdi:lightbulb', 'original_name': 'fake-device-1 Panel Light', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Panel Light', @@ -113,6 +114,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 Quiet', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Quiet', @@ -141,6 +143,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 Fresh Air', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Fresh Air', @@ -169,6 +172,7 @@ 'original_icon': None, 'original_name': 'fake-device-1 XFan', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_XFan', @@ -197,6 +201,7 @@ 'original_icon': 'mdi:pine-tree', 'original_name': 'fake-device-1 Health mode', 'platform': 'gree', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbcc112233_Health mode', diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 4c408f2887e..aa9294472f0 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -50,6 +50,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -85,6 +86,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Provision Preferred Thread Credentials', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_112_119', @@ -122,6 +124,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2579', @@ -161,6 +164,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Filter lifetime', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32896_32900', @@ -200,6 +204,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 PM2.5 Density', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2576_2580', @@ -247,6 +252,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Thread Capabilities', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_112_115', @@ -301,6 +307,7 @@ 'original_icon': None, 'original_name': 'Airversa AP2 1808 Thread Status', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_112_117', @@ -346,6 +353,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Airversa AP2 1808 Lock Physical Controls', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32839', @@ -382,6 +390,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Airversa AP2 1808 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32843', @@ -418,6 +427,7 @@ 'original_icon': 'mdi:power-sleep', 'original_name': 'Airversa AP2 1808 Sleep Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_32832_32842', @@ -487,6 +497,7 @@ 'original_icon': None, 'original_name': 'eufy HomeBase2-0AAA Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -551,6 +562,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000 Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_160', @@ -587,6 +599,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -622,6 +635,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-0000', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4', @@ -660,6 +674,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-0000 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_101', @@ -699,6 +714,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-0000 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_80_83', @@ -764,6 +780,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_160', @@ -800,6 +817,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -835,6 +853,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2', @@ -873,6 +892,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_101', @@ -912,6 +932,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_80_83', @@ -977,6 +998,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_160', @@ -1013,6 +1035,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -1048,6 +1071,7 @@ 'original_icon': None, 'original_name': 'eufyCam2-000A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3', @@ -1086,6 +1110,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'eufyCam2-000A Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_101', @@ -1125,6 +1150,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'eufyCam2-000A Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_80_83', @@ -1194,6 +1220,7 @@ 'original_icon': 'mdi:security', 'original_name': 'Aqara-Hub-E1-00A0 Security System', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -1234,6 +1261,7 @@ 'original_icon': None, 'original_name': 'Aqara-Hub-E1-00A0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -1274,6 +1302,7 @@ 'original_icon': 'mdi:volume-high', 'original_name': 'Aqara-Hub-E1-00A0 Volume', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17_1114116', @@ -1314,6 +1343,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Aqara-Hub-E1-00A0 Pairing Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17_1114117', @@ -1379,6 +1409,7 @@ 'original_icon': None, 'original_name': 'Contact Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_4', @@ -1415,6 +1446,7 @@ 'original_icon': None, 'original_name': 'Contact Sensor Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_1_65537', @@ -1452,6 +1484,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Contact Sensor Battery Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_33_5', @@ -1524,6 +1557,7 @@ 'original_icon': 'mdi:security', 'original_name': 'Aqara Hub-1563 Security System', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_66304', @@ -1564,6 +1598,7 @@ 'original_icon': None, 'original_name': 'Aqara Hub-1563 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -1603,6 +1638,7 @@ 'original_icon': None, 'original_name': 'Aqara Hub-1563 Lightbulb-1563', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65792', @@ -1647,6 +1683,7 @@ 'original_icon': 'mdi:volume-high', 'original_name': 'Aqara Hub-1563 Volume', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65536_65541', @@ -1687,6 +1724,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Aqara Hub-1563 Pairing Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_65536_65538', @@ -1756,6 +1794,7 @@ 'original_icon': None, 'original_name': 'Programmable Switch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_65537', @@ -1793,6 +1832,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Programmable Switch Battery Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_5', @@ -1865,6 +1905,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Motion', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_500', @@ -1901,6 +1942,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -1936,6 +1978,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -1976,6 +2019,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Nightlight', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1100', @@ -2017,6 +2061,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_800_802', @@ -2056,6 +2101,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'ArloBabyA0 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_700', @@ -2097,6 +2143,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_900', @@ -2137,6 +2184,7 @@ 'original_icon': None, 'original_name': 'ArloBabyA0 Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1000', @@ -2175,6 +2223,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_300_302', @@ -2211,6 +2260,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'ArloBabyA0 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_400_402', @@ -2280,6 +2330,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -2317,6 +2368,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_18', @@ -2357,6 +2409,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Current', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_30', @@ -2397,6 +2450,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_20', @@ -2437,6 +2491,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_32', @@ -2477,6 +2532,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13_19', @@ -2517,6 +2573,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25_31', @@ -2555,6 +2612,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Outlet A', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_13', @@ -2591,6 +2649,7 @@ 'original_icon': None, 'original_name': 'InWall Outlet-0394DE Outlet B', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_25', @@ -2660,6 +2719,7 @@ 'original_icon': None, 'original_name': 'Basement', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_56', @@ -2696,6 +2756,7 @@ 'original_icon': None, 'original_name': 'Basement Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_4101', @@ -2733,6 +2794,7 @@ 'original_icon': None, 'original_name': 'Basement Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_55', @@ -2800,6 +2862,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -2836,6 +2899,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -2872,6 +2936,7 @@ 'original_icon': None, 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -2907,6 +2972,7 @@ 'original_icon': None, 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -2953,6 +3019,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -3012,6 +3079,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -3057,6 +3125,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -3099,6 +3168,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -3139,6 +3209,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -3206,6 +3277,7 @@ 'original_icon': None, 'original_name': 'Kitchen', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_56', @@ -3242,6 +3314,7 @@ 'original_icon': None, 'original_name': 'Kitchen Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2053', @@ -3279,6 +3352,7 @@ 'original_icon': None, 'original_name': 'Kitchen Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_55', @@ -3346,6 +3420,7 @@ 'original_icon': None, 'original_name': 'Porch', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_56', @@ -3382,6 +3457,7 @@ 'original_icon': None, 'original_name': 'Porch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_3077', @@ -3419,6 +3495,7 @@ 'original_icon': None, 'original_name': 'Porch Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_55', @@ -3490,6 +3567,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -3526,6 +3604,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -3562,6 +3641,7 @@ 'original_icon': None, 'original_name': 'HomeW Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -3597,6 +3677,7 @@ 'original_icon': None, 'original_name': 'HomeW Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -3643,6 +3724,7 @@ 'original_icon': None, 'original_name': 'HomeW', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -3702,6 +3784,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -3747,6 +3830,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'HomeW Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -3789,6 +3873,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -3829,6 +3914,7 @@ 'original_icon': None, 'original_name': 'HomeW Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -3900,6 +3986,7 @@ 'original_icon': None, 'original_name': 'My ecobee Motion', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -3936,6 +4023,7 @@ 'original_icon': None, 'original_name': 'My ecobee Occupancy', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -3972,6 +4060,7 @@ 'original_icon': None, 'original_name': 'My ecobee Clear Hold', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_48', @@ -4007,6 +4096,7 @@ 'original_icon': None, 'original_name': 'My ecobee Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -4057,6 +4147,7 @@ 'original_icon': None, 'original_name': 'My ecobee', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -4121,6 +4212,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Mode', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ecobee_mode', 'unique_id': '00:00:00:00:00:00_1_16_33', @@ -4166,6 +4258,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'My ecobee Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_16_21', @@ -4208,6 +4301,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_24', @@ -4248,6 +4342,7 @@ 'original_icon': None, 'original_name': 'My ecobee Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16_19', @@ -4319,6 +4414,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_56', @@ -4355,6 +4451,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_57', @@ -4391,6 +4488,7 @@ 'original_icon': None, 'original_name': 'Master Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -4428,6 +4526,7 @@ 'original_icon': None, 'original_name': 'Master Fan Light Level', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -4468,6 +4567,7 @@ 'original_icon': None, 'original_name': 'Master Fan Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_55', @@ -4506,6 +4606,7 @@ 'original_icon': None, 'original_name': 'Master Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_16', @@ -4574,6 +4675,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -4614,6 +4716,7 @@ 'original_icon': 'mdi:elevation-rise', 'original_name': 'Eve Degree AA11 Elevation', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -4659,6 +4762,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Eve Degree AA11 Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_22_25', @@ -4701,6 +4805,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Air Pressure', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_32', @@ -4741,6 +4846,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Eve Degree AA11 Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -4782,6 +4888,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_27', @@ -4822,6 +4929,7 @@ 'original_icon': None, 'original_name': 'Eve Degree AA11 Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -4893,6 +5001,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -4930,6 +5039,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Amps', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_33', @@ -4970,6 +5080,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Energy kWh', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_35', @@ -5010,6 +5121,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_34', @@ -5050,6 +5162,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF Volts', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_32', @@ -5088,6 +5201,7 @@ 'original_icon': None, 'original_name': 'Eve Energy 50FF', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28', @@ -5124,6 +5238,7 @@ 'original_icon': 'mdi:lock-open', 'original_name': 'Eve Energy 50FF Lock Physical Controls', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_28_36', @@ -5193,6 +5308,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -5228,6 +5344,7 @@ 'original_icon': 'mdi:cog', 'original_name': 'HAA-C718B3 Setup', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1010_1012', @@ -5264,6 +5381,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Update', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1010_1011', @@ -5302,6 +5420,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -5371,6 +5490,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_7', @@ -5406,6 +5526,7 @@ 'original_icon': None, 'original_name': 'HAA-C718B3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_8', @@ -5474,6 +5595,7 @@ 'original_icon': None, 'original_name': 'Ceiling Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_1_2', @@ -5511,6 +5633,7 @@ 'original_icon': None, 'original_name': 'Ceiling Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_766313939_8', @@ -5580,6 +5703,7 @@ 'original_icon': None, 'original_name': 'Home Assistant Bridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -5644,6 +5768,7 @@ 'original_icon': None, 'original_name': 'Living Room Fan Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_1_2', @@ -5681,6 +5806,7 @@ 'original_icon': None, 'original_name': 'Living Room Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1256851357_8', @@ -5756,6 +5882,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -5807,6 +5934,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner SlaveID 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -5864,6 +5992,7 @@ 'original_icon': None, 'original_name': 'Air Conditioner Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9_11', @@ -5935,6 +6064,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_1_6', @@ -5978,6 +6108,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276914_2816', @@ -6050,6 +6181,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_1_6', @@ -6093,6 +6225,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462395276939_2816', @@ -6165,6 +6298,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_1_6', @@ -6208,6 +6342,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403113447_2816', @@ -6280,6 +6415,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_1_6', @@ -6323,6 +6459,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance candle', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462403233419_2816', @@ -6395,6 +6532,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_1_6', @@ -6438,6 +6576,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412411853_2816', @@ -6527,6 +6666,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_1_6', @@ -6570,6 +6710,7 @@ 'original_icon': None, 'original_name': 'Hue ambiance spot', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462412413293_2816', @@ -6659,6 +6800,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_1_22', @@ -6698,6 +6840,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410585088', @@ -6742,6 +6885,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410650624', @@ -6786,6 +6930,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410716160', @@ -6830,6 +6975,7 @@ 'original_icon': None, 'original_name': 'Hue dimmer switch button 4', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'button', 'unique_id': '00:00:00:00:00:00_6623462389072572_588410781696', @@ -6872,6 +7018,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Hue dimmer switch battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462389072572_644245094400', @@ -6940,6 +7087,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_1_6', @@ -6979,6 +7127,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378982941_2816', @@ -7047,6 +7196,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_1_6', @@ -7086,6 +7236,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462378983942_2816', @@ -7154,6 +7305,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_1_6', @@ -7193,6 +7345,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379122122_2816', @@ -7261,6 +7414,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_1_6', @@ -7300,6 +7454,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462379123707_2816', @@ -7368,6 +7523,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_1_6', @@ -7407,6 +7563,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114163_2816', @@ -7475,6 +7632,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_1_6', @@ -7514,6 +7672,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462383114193_2816', @@ -7582,6 +7741,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_1_6', @@ -7621,6 +7781,7 @@ 'original_icon': None, 'original_name': 'Hue white lamp', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_6623462385996792_2816', @@ -7689,6 +7850,7 @@ 'original_icon': None, 'original_name': 'Philips hue - 482544 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -7757,6 +7919,7 @@ 'original_icon': None, 'original_name': 'Koogeek-LS1-20833F Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -7796,6 +7959,7 @@ 'original_icon': None, 'original_name': 'Koogeek-LS1-20833F Light Strip', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -7868,6 +8032,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -7905,6 +8070,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21_22', @@ -7943,6 +8109,7 @@ 'original_icon': None, 'original_name': 'Koogeek-P1-A00AA0 outlet', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7', @@ -8012,6 +8179,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -8049,6 +8217,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14_18', @@ -8087,6 +8256,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Switch 1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -8122,6 +8292,7 @@ 'original_icon': None, 'original_name': 'Koogeek-SW2-187A91 Switch 2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_11', @@ -8190,6 +8361,7 @@ 'original_icon': None, 'original_name': 'Lennox Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8234,6 +8406,7 @@ 'original_icon': None, 'original_name': 'Lennox', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100', @@ -8289,6 +8462,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Lennox Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_100_105', @@ -8331,6 +8505,7 @@ 'original_icon': None, 'original_name': 'Lennox Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_107', @@ -8371,6 +8546,7 @@ 'original_icon': None, 'original_name': 'Lennox Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_100_103', @@ -8442,6 +8618,7 @@ 'original_icon': None, 'original_name': 'LG webOS TV AF80 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8487,6 +8664,7 @@ 'original_icon': None, 'original_name': 'LG webOS TV AF80', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', @@ -8534,6 +8712,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'LG webOS TV AF80 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_80_82', @@ -8603,6 +8782,7 @@ 'original_icon': None, 'original_name': 'Caséta® Wireless Fan Speed Control Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_1_85899345921', @@ -8640,6 +8820,7 @@ 'original_icon': None, 'original_name': 'Caséta® Wireless Fan Speed Control', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_21474836482_2', @@ -8709,6 +8890,7 @@ 'original_icon': None, 'original_name': 'Smart Bridge 2 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_85899345921', @@ -8777,6 +8959,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -8812,6 +8995,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-1', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -8847,6 +9031,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-2', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_15', @@ -8882,6 +9067,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-3', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_18', @@ -8917,6 +9103,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc Outlet-4', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_21', @@ -8952,6 +9139,7 @@ 'original_icon': None, 'original_name': 'MSS425F-15cc USB', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24', @@ -9020,6 +9208,7 @@ 'original_icon': None, 'original_name': 'MSS565-28da Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9059,6 +9248,7 @@ 'original_icon': None, 'original_name': 'MSS565-28da Dimmer Switch', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_12', @@ -9133,6 +9323,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9177,6 +9368,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Thermostat', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20', @@ -9229,6 +9421,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Display', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_40', @@ -9273,6 +9466,7 @@ 'original_icon': 'mdi:thermometer', 'original_name': 'Mysa-85dda9 Temperature Display Units', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'temperature_display_units', 'unique_id': '00:00:00:00:00:00_1_20_26', @@ -9315,6 +9509,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_27', @@ -9355,6 +9550,7 @@ 'original_icon': None, 'original_name': 'Mysa-85dda9 Current Temperature', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_25', @@ -9426,6 +9622,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -9461,6 +9658,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Provision Preferred Thread Credentials', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_31_119', @@ -9505,6 +9703,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_19', @@ -9573,6 +9772,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Thread Capabilities', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_node_capabilities', 'unique_id': '00:00:00:00:00:00_1_31_115', @@ -9627,6 +9827,7 @@ 'original_icon': None, 'original_name': 'Nanoleaf Strip 3B32 Thread Status', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thread_status', 'unique_id': '00:00:00:00:00:00_1_31_117', @@ -9705,6 +9906,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658 Motion Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -9741,6 +9943,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -9776,6 +9979,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1', @@ -9818,6 +10022,7 @@ 'original_icon': None, 'original_name': 'Netatmo-Doorbell-g738658', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'doorbell', 'unique_id': '00:00:00:00:00:00_1_49', @@ -9860,6 +10065,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_51_52', @@ -9896,6 +10102,7 @@ 'original_icon': 'mdi:volume-mute', 'original_name': 'Netatmo-Doorbell-g738658 Mute', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8_9', @@ -9965,6 +10172,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Carbon Monoxide Sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_22', @@ -10001,6 +10209,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Low Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_36', @@ -10037,6 +10246,7 @@ 'original_icon': None, 'original_name': 'Smart CO Alarm Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_7_3', @@ -10105,6 +10315,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_7', @@ -10142,6 +10353,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Air Quality', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_24_8', @@ -10181,6 +10393,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Carbon Dioxide sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_10', @@ -10221,6 +10434,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Humidity sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_14', @@ -10261,6 +10475,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Noise', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_20_21', @@ -10301,6 +10516,7 @@ 'original_icon': None, 'original_name': 'Healthy Home Coach Temperature sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_17', @@ -10372,6 +10588,7 @@ 'original_icon': None, 'original_name': 'RainMachine-00ce4a Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -10407,6 +10624,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_512', @@ -10446,6 +10664,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_768', @@ -10485,6 +10704,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1024', @@ -10524,6 +10744,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1280', @@ -10563,6 +10784,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1536', @@ -10602,6 +10824,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1792', @@ -10641,6 +10864,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2048', @@ -10680,6 +10904,7 @@ 'original_icon': 'mdi:water', 'original_name': 'RainMachine-00ce4a', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_2304', @@ -10752,6 +10977,7 @@ 'original_icon': None, 'original_name': 'Master Bath South Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -10787,6 +11013,7 @@ 'original_icon': None, 'original_name': 'Master Bath South RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -10826,6 +11053,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'Master Bath South RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -10894,6 +11122,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -10958,6 +11187,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartShade Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -10993,6 +11223,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartShade RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -11032,6 +11263,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'RYSE SmartShade RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -11104,6 +11336,7 @@ 'original_icon': None, 'original_name': 'BR Left Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_1_2', @@ -11139,6 +11372,7 @@ 'original_icon': None, 'original_name': 'BR Left RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_48', @@ -11178,6 +11412,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'BR Left RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_4_64', @@ -11246,6 +11481,7 @@ 'original_icon': None, 'original_name': 'LR Left Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_2', @@ -11281,6 +11517,7 @@ 'original_icon': None, 'original_name': 'LR Left RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_48', @@ -11320,6 +11557,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'LR Left RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_64', @@ -11388,6 +11626,7 @@ 'original_icon': None, 'original_name': 'LR Right Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_2', @@ -11423,6 +11662,7 @@ 'original_icon': None, 'original_name': 'LR Right RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_48', @@ -11462,6 +11702,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'LR Right RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_64', @@ -11530,6 +11771,7 @@ 'original_icon': None, 'original_name': 'RYSE SmartBridge Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -11594,6 +11836,7 @@ 'original_icon': None, 'original_name': 'RZSS Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_1_2', @@ -11629,6 +11872,7 @@ 'original_icon': None, 'original_name': 'RZSS RYSE Shade', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_48', @@ -11668,6 +11912,7 @@ 'original_icon': 'mdi:battery-unknown', 'original_name': 'RZSS RYSE Shade Battery', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_5_64', @@ -11740,6 +11985,7 @@ 'original_icon': None, 'original_name': 'SENSE Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_3', @@ -11775,6 +12021,7 @@ 'original_icon': None, 'original_name': 'SENSE Lock Mechanism', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -11844,6 +12091,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -11881,6 +12129,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Fan', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_8', @@ -11926,6 +12175,7 @@ 'original_icon': None, 'original_name': 'SIMPLEconnect Fan-06F674 Hunter Light', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_29', @@ -12000,6 +12250,7 @@ 'original_icon': None, 'original_name': 'VELUX Gateway Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_6', @@ -12064,6 +12315,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_1_7', @@ -12101,6 +12353,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Carbon Dioxide sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_14', @@ -12141,6 +12394,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Humidity sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_11', @@ -12181,6 +12435,7 @@ 'original_icon': None, 'original_name': 'VELUX Sensor Temperature sensor', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_2_8', @@ -12248,6 +12503,7 @@ 'original_icon': None, 'original_name': 'VELUX Window Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_1_7', @@ -12283,6 +12539,7 @@ 'original_icon': None, 'original_name': 'VELUX Window Roof Window', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_3_8', @@ -12354,6 +12611,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12396,6 +12654,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30', @@ -12446,6 +12705,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Mood Light', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_9', @@ -12505,6 +12765,7 @@ 'original_icon': 'mdi:water', 'original_name': 'VOCOlinc-Flowerbud-0d324b Spray Quantity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_38', @@ -12547,6 +12808,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-Flowerbud-0d324b Current Humidity', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_30_33', @@ -12618,6 +12880,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Identify', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_1_2', @@ -12655,6 +12918,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Power', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48_97', @@ -12693,6 +12957,7 @@ 'original_icon': None, 'original_name': 'VOCOlinc-VP3-123456 Outlet', 'platform': 'homekit_controller', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '00:00:00:00:00:00_1_48', diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 702196d4574..f6799d7a691 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -139,6 +139,7 @@ 'original_icon': None, 'original_name': 'Sensed A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_a', 'unique_id': '/12.111111111111/sensed.A', @@ -167,6 +168,7 @@ 'original_icon': None, 'original_name': 'Sensed B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_b', 'unique_id': '/12.111111111111/sensed.B', @@ -540,6 +542,7 @@ 'original_icon': None, 'original_name': 'Sensed 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_0', 'unique_id': '/29.111111111111/sensed.0', @@ -568,6 +571,7 @@ 'original_icon': None, 'original_name': 'Sensed 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_1', 'unique_id': '/29.111111111111/sensed.1', @@ -596,6 +600,7 @@ 'original_icon': None, 'original_name': 'Sensed 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_2', 'unique_id': '/29.111111111111/sensed.2', @@ -624,6 +629,7 @@ 'original_icon': None, 'original_name': 'Sensed 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_3', 'unique_id': '/29.111111111111/sensed.3', @@ -652,6 +658,7 @@ 'original_icon': None, 'original_name': 'Sensed 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_4', 'unique_id': '/29.111111111111/sensed.4', @@ -680,6 +687,7 @@ 'original_icon': None, 'original_name': 'Sensed 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_5', 'unique_id': '/29.111111111111/sensed.5', @@ -708,6 +716,7 @@ 'original_icon': None, 'original_name': 'Sensed 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_6', 'unique_id': '/29.111111111111/sensed.6', @@ -736,6 +745,7 @@ 'original_icon': None, 'original_name': 'Sensed 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_7', 'unique_id': '/29.111111111111/sensed.7', @@ -934,6 +944,7 @@ 'original_icon': None, 'original_name': 'Sensed A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_a', 'unique_id': '/3A.111111111111/sensed.A', @@ -962,6 +973,7 @@ 'original_icon': None, 'original_name': 'Sensed B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sensed_b', 'unique_id': '/3A.111111111111/sensed.B', @@ -1273,6 +1285,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_0', 'unique_id': '/EF.111111111113/hub/short.0', @@ -1301,6 +1314,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_1', 'unique_id': '/EF.111111111113/hub/short.1', @@ -1329,6 +1343,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_2', 'unique_id': '/EF.111111111113/hub/short.2', @@ -1357,6 +1372,7 @@ 'original_icon': None, 'original_name': 'Hub short on branch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_short_3', 'unique_id': '/EF.111111111113/hub/short.3', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 0664d7e5402..46875b2ab1a 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -104,6 +104,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/10.111111111111/temperature', @@ -186,6 +187,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/temperature', @@ -216,6 +218,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/12.111111111111/TAI8570/pressure', @@ -313,6 +316,7 @@ 'original_icon': None, 'original_name': 'Counter A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_a', 'unique_id': '/1D.111111111111/counter.A', @@ -343,6 +347,7 @@ 'original_icon': None, 'original_name': 'Counter B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_b', 'unique_id': '/1D.111111111111/counter.B', @@ -463,6 +468,7 @@ 'original_icon': None, 'original_name': 'Counter A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_a', 'unique_id': '/1D.111111111111/counter.A', @@ -493,6 +499,7 @@ 'original_icon': None, 'original_name': 'Counter B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'counter_b', 'unique_id': '/1D.111111111111/counter.B', @@ -588,6 +595,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/22.111111111111/temperature', @@ -670,6 +678,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/temperature', @@ -700,6 +709,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/humidity', @@ -730,6 +740,7 @@ 'original_icon': None, 'original_name': 'HIH3600 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih3600', 'unique_id': '/26.111111111111/HIH3600/humidity', @@ -760,6 +771,7 @@ 'original_icon': None, 'original_name': 'HIH4000 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih4000', 'unique_id': '/26.111111111111/HIH4000/humidity', @@ -790,6 +802,7 @@ 'original_icon': None, 'original_name': 'HIH5030 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_hih5030', 'unique_id': '/26.111111111111/HIH5030/humidity', @@ -820,6 +833,7 @@ 'original_icon': None, 'original_name': 'HTM1735 humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_htm1735', 'unique_id': '/26.111111111111/HTM1735/humidity', @@ -850,6 +864,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/B1-R1-A/pressure', @@ -880,6 +895,7 @@ 'original_icon': None, 'original_name': 'Illuminance', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/26.111111111111/S3-R1-A/illuminance', @@ -910,6 +926,7 @@ 'original_icon': None, 'original_name': 'VAD voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vad', 'unique_id': '/26.111111111111/VAD', @@ -940,6 +957,7 @@ 'original_icon': None, 'original_name': 'VDD voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vdd', 'unique_id': '/26.111111111111/VDD', @@ -970,6 +988,7 @@ 'original_icon': None, 'original_name': 'VIS voltage difference', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis', 'unique_id': '/26.111111111111/vis', @@ -1202,6 +1221,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.111111111111/temperature', @@ -1284,6 +1304,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222222/temperature', @@ -1366,6 +1387,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/28.222222222223/temperature', @@ -1485,6 +1507,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/temperature', @@ -1515,6 +1538,7 @@ 'original_icon': None, 'original_name': 'Thermocouple K temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'thermocouple_temperature_k', 'unique_id': '/30.111111111111/typeX/temperature', @@ -1545,6 +1569,7 @@ 'original_icon': None, 'original_name': 'Voltage', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/30.111111111111/volt', @@ -1575,6 +1600,7 @@ 'original_icon': None, 'original_name': 'VIS voltage gradient', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'voltage_vis_gradient', 'unique_id': '/30.111111111111/vis', @@ -1739,6 +1765,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/3B.111111111111/temperature', @@ -1821,6 +1848,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/42.111111111111/temperature', @@ -1903,6 +1931,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/temperature', @@ -1933,6 +1962,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/pressure', @@ -1963,6 +1993,7 @@ 'original_icon': None, 'original_name': 'Illuminance', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/light', @@ -1993,6 +2024,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.111111111111/EDS0068/humidity', @@ -2120,6 +2152,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/temperature', @@ -2150,6 +2183,7 @@ 'original_icon': None, 'original_name': 'Pressure', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/7E.222222222222/EDS0066/pressure', @@ -2247,6 +2281,7 @@ 'original_icon': None, 'original_name': 'Humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/humidity_corrected', @@ -2277,6 +2312,7 @@ 'original_icon': None, 'original_name': 'Raw humidity', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'humidity_raw', 'unique_id': '/EF.111111111111/humidity/humidity_raw', @@ -2307,6 +2343,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '/EF.111111111111/humidity/temperature', @@ -2419,6 +2456,7 @@ 'original_icon': None, 'original_name': 'Wetness 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wetness_0', 'unique_id': '/EF.111111111112/moisture/sensor.0', @@ -2449,6 +2487,7 @@ 'original_icon': None, 'original_name': 'Wetness 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wetness_1', 'unique_id': '/EF.111111111112/moisture/sensor.1', @@ -2479,6 +2518,7 @@ 'original_icon': None, 'original_name': 'Moisture 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_2', 'unique_id': '/EF.111111111112/moisture/sensor.2', @@ -2509,6 +2549,7 @@ 'original_icon': None, 'original_name': 'Moisture 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_3', 'unique_id': '/EF.111111111112/moisture/sensor.3', diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 55ea7be1fa6..67d38a09b85 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -65,6 +65,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio', 'unique_id': '/05.111111111111/PIO', @@ -179,6 +180,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_a', 'unique_id': '/12.111111111111/PIO.A', @@ -207,6 +209,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_b', 'unique_id': '/12.111111111111/PIO.B', @@ -235,6 +238,7 @@ 'original_icon': None, 'original_name': 'Latch A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_a', 'unique_id': '/12.111111111111/latch.A', @@ -263,6 +267,7 @@ 'original_icon': None, 'original_name': 'Latch B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_b', 'unique_id': '/12.111111111111/latch.B', @@ -512,6 +517,7 @@ 'original_icon': None, 'original_name': 'Current A/D control', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'iad', 'unique_id': '/26.111111111111/IAD', @@ -700,6 +706,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_0', 'unique_id': '/29.111111111111/PIO.0', @@ -728,6 +735,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_1', 'unique_id': '/29.111111111111/PIO.1', @@ -756,6 +764,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_2', 'unique_id': '/29.111111111111/PIO.2', @@ -784,6 +793,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_3', 'unique_id': '/29.111111111111/PIO.3', @@ -812,6 +822,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_4', 'unique_id': '/29.111111111111/PIO.4', @@ -840,6 +851,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_5', 'unique_id': '/29.111111111111/PIO.5', @@ -868,6 +880,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_6', 'unique_id': '/29.111111111111/PIO.6', @@ -896,6 +909,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_7', 'unique_id': '/29.111111111111/PIO.7', @@ -924,6 +938,7 @@ 'original_icon': None, 'original_name': 'Latch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_0', 'unique_id': '/29.111111111111/latch.0', @@ -952,6 +967,7 @@ 'original_icon': None, 'original_name': 'Latch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_1', 'unique_id': '/29.111111111111/latch.1', @@ -980,6 +996,7 @@ 'original_icon': None, 'original_name': 'Latch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_2', 'unique_id': '/29.111111111111/latch.2', @@ -1008,6 +1025,7 @@ 'original_icon': None, 'original_name': 'Latch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_3', 'unique_id': '/29.111111111111/latch.3', @@ -1036,6 +1054,7 @@ 'original_icon': None, 'original_name': 'Latch 4', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_4', 'unique_id': '/29.111111111111/latch.4', @@ -1064,6 +1083,7 @@ 'original_icon': None, 'original_name': 'Latch 5', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_5', 'unique_id': '/29.111111111111/latch.5', @@ -1092,6 +1112,7 @@ 'original_icon': None, 'original_name': 'Latch 6', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_6', 'unique_id': '/29.111111111111/latch.6', @@ -1120,6 +1141,7 @@ 'original_icon': None, 'original_name': 'Latch 7', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'latch_7', 'unique_id': '/29.111111111111/latch.7', @@ -1414,6 +1436,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output A', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_a', 'unique_id': '/3A.111111111111/PIO.A', @@ -1442,6 +1465,7 @@ 'original_icon': None, 'original_name': 'Programmed input-output B', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'pio_b', 'unique_id': '/3A.111111111111/PIO.B', @@ -1716,6 +1740,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_0', 'unique_id': '/EF.111111111112/moisture/is_leaf.0', @@ -1744,6 +1769,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_1', 'unique_id': '/EF.111111111112/moisture/is_leaf.1', @@ -1772,6 +1798,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_2', 'unique_id': '/EF.111111111112/moisture/is_leaf.2', @@ -1800,6 +1827,7 @@ 'original_icon': None, 'original_name': 'Leaf sensor 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'leaf_sensor_3', 'unique_id': '/EF.111111111112/moisture/is_leaf.3', @@ -1828,6 +1856,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_0', 'unique_id': '/EF.111111111112/moisture/is_moisture.0', @@ -1856,6 +1885,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_1', 'unique_id': '/EF.111111111112/moisture/is_moisture.1', @@ -1884,6 +1914,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_2', 'unique_id': '/EF.111111111112/moisture/is_moisture.2', @@ -1912,6 +1943,7 @@ 'original_icon': None, 'original_name': 'Moisture sensor 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'moisture_sensor_3', 'unique_id': '/EF.111111111112/moisture/is_moisture.3', @@ -2073,6 +2105,7 @@ 'original_icon': None, 'original_name': 'Hub branch 0', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_0', 'unique_id': '/EF.111111111113/hub/branch.0', @@ -2101,6 +2134,7 @@ 'original_icon': None, 'original_name': 'Hub branch 1', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_1', 'unique_id': '/EF.111111111113/hub/branch.1', @@ -2129,6 +2163,7 @@ 'original_icon': None, 'original_name': 'Hub branch 2', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_2', 'unique_id': '/EF.111111111113/hub/branch.2', @@ -2157,6 +2192,7 @@ 'original_icon': None, 'original_name': 'Hub branch 3', 'platform': 'onewire', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hub_branch_3', 'unique_id': '/EF.111111111113/hub/branch.3', diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 9625810bedb..6d5e509ab6b 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -53,6 +53,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -81,6 +82,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -109,6 +111,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -137,6 +140,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -165,6 +169,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -193,6 +198,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -324,6 +330,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', @@ -352,6 +359,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', @@ -380,6 +388,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -408,6 +417,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -436,6 +446,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -464,6 +475,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -492,6 +504,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -520,6 +533,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -673,6 +687,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -701,6 +716,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -729,6 +745,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -827,6 +844,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -855,6 +873,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -883,6 +902,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -911,6 +931,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', @@ -939,6 +960,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777999_hatch_status', @@ -967,6 +989,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', @@ -995,6 +1018,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', @@ -1023,6 +1047,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777999_driver_door_status', @@ -1051,6 +1076,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777999_passenger_door_status', @@ -1215,6 +1241,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -1243,6 +1270,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -1271,6 +1299,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -1299,6 +1328,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -1327,6 +1357,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -1355,6 +1386,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -1486,6 +1518,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_plugged_in', @@ -1514,6 +1547,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_charging', @@ -1542,6 +1576,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_lock_status', @@ -1570,6 +1605,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777123_hatch_status', @@ -1598,6 +1634,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', @@ -1626,6 +1663,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', @@ -1654,6 +1692,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777123_driver_door_status', @@ -1682,6 +1721,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777123_passenger_door_status', @@ -1835,6 +1875,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -1863,6 +1904,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -1891,6 +1933,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -1989,6 +2032,7 @@ 'original_icon': None, 'original_name': 'Plug', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_plugged_in', @@ -2017,6 +2061,7 @@ 'original_icon': None, 'original_name': 'Charging', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_charging', @@ -2045,6 +2090,7 @@ 'original_icon': 'mdi:fan-off', 'original_name': 'HVAC', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_status', 'unique_id': 'vf1aaaaa555777999_hvac_status', @@ -2073,6 +2119,7 @@ 'original_icon': None, 'original_name': 'Lock', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_lock_status', @@ -2101,6 +2148,7 @@ 'original_icon': None, 'original_name': 'Hatch', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hatch_status', 'unique_id': 'vf1aaaaa555777999_hatch_status', @@ -2129,6 +2177,7 @@ 'original_icon': None, 'original_name': 'Rear left door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_left_door_status', 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', @@ -2157,6 +2206,7 @@ 'original_icon': None, 'original_name': 'Rear right door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'rear_right_door_status', 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', @@ -2185,6 +2235,7 @@ 'original_icon': None, 'original_name': 'Driver door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'driver_door_status', 'unique_id': 'vf1aaaaa555777999_driver_door_status', @@ -2213,6 +2264,7 @@ 'original_icon': None, 'original_name': 'Passenger door', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'passenger_door_status', 'unique_id': 'vf1aaaaa555777999_passenger_door_status', diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 8c56a3842ea..968b20daa5b 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -53,6 +53,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -129,6 +130,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -157,6 +159,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777123_start_charge', @@ -185,6 +188,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777123_stop_charge', @@ -283,6 +287,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -311,6 +316,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -339,6 +345,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -437,6 +444,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -465,6 +473,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -493,6 +502,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -591,6 +601,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -667,6 +678,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', @@ -695,6 +707,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777123_start_charge', @@ -723,6 +736,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777123_stop_charge', @@ -821,6 +835,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -849,6 +864,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -877,6 +893,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', @@ -975,6 +992,7 @@ 'original_icon': 'mdi:air-conditioner', 'original_name': 'Start air conditioner', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_air_conditioner', 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', @@ -1003,6 +1021,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Start charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'start_charge', 'unique_id': 'vf1aaaaa555777999_start_charge', @@ -1031,6 +1050,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Stop charge', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'stop_charge', 'unique_id': 'vf1aaaaa555777999_stop_charge', diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 474791791d9..8a215f3fdda 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -53,6 +53,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -130,6 +131,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -244,6 +246,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777999_location', @@ -321,6 +324,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -401,6 +405,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777123_location', @@ -518,6 +523,7 @@ 'original_icon': 'mdi:car', 'original_name': 'Location', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location', 'unique_id': 'vf1aaaaa555777999_location', diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index c5bbc6b2002..c862e90f289 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -96,6 +96,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777123_charge_mode', @@ -183,6 +184,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -270,6 +272,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -394,6 +397,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777123_charge_mode', @@ -481,6 +485,7 @@ 'original_icon': 'mdi:calendar-remove', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', @@ -568,6 +573,7 @@ 'original_icon': 'mdi:calendar-clock', 'original_name': 'Charge mode', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_mode', 'unique_id': 'vf1aaaaa555777999_charge_mode', diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index 46b231ac7ef..f49dbf7963f 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -55,6 +55,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -85,6 +86,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -115,6 +117,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -143,6 +146,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -171,6 +175,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -199,6 +204,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -339,6 +345,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', @@ -378,6 +385,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777123_charge_state', @@ -408,6 +416,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', @@ -438,6 +447,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777123_charging_power', @@ -473,6 +483,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777123_plug_state', @@ -503,6 +514,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777123_battery_autonomy', @@ -533,6 +545,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777123_battery_available_energy', @@ -563,6 +576,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777123_battery_temperature', @@ -591,6 +605,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777123_battery_last_activity', @@ -621,6 +636,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -651,6 +667,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -681,6 +698,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -709,6 +727,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -737,6 +756,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -765,6 +785,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -1036,6 +1057,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -1075,6 +1097,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -1105,6 +1128,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -1135,6 +1159,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -1170,6 +1195,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -1200,6 +1226,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -1230,6 +1257,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -1260,6 +1288,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -1288,6 +1317,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -1318,6 +1348,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -1348,6 +1379,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -1376,6 +1408,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -1404,6 +1437,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -1432,6 +1466,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -1460,6 +1495,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -1727,6 +1763,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -1766,6 +1803,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -1796,6 +1834,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -1826,6 +1865,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -1861,6 +1901,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -1891,6 +1932,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -1921,6 +1963,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -1951,6 +1994,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -1979,6 +2023,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -2009,6 +2054,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -2039,6 +2085,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -2067,6 +2114,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -2095,6 +2143,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -2123,6 +2172,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777999_location_last_activity', @@ -2151,6 +2201,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -2179,6 +2230,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -2457,6 +2509,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -2487,6 +2540,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -2517,6 +2571,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -2545,6 +2600,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -2573,6 +2629,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -2601,6 +2658,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -2741,6 +2799,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777123_battery_level', @@ -2780,6 +2839,7 @@ 'original_icon': 'mdi:flash', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777123_charge_state', @@ -2810,6 +2870,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', @@ -2840,6 +2901,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777123_charging_power', @@ -2875,6 +2937,7 @@ 'original_icon': 'mdi:power-plug', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777123_plug_state', @@ -2905,6 +2968,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777123_battery_autonomy', @@ -2935,6 +2999,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777123_battery_available_energy', @@ -2965,6 +3030,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777123_battery_temperature', @@ -2993,6 +3059,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777123_battery_last_activity', @@ -3023,6 +3090,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777123_mileage', @@ -3053,6 +3121,7 @@ 'original_icon': 'mdi:gas-station', 'original_name': 'Fuel autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_autonomy', 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', @@ -3083,6 +3152,7 @@ 'original_icon': 'mdi:fuel', 'original_name': 'Fuel quantity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'fuel_quantity', 'unique_id': 'vf1aaaaa555777123_fuel_quantity', @@ -3111,6 +3181,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777123_location_last_activity', @@ -3139,6 +3210,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777123_res_state', @@ -3167,6 +3239,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777123_res_state_code', @@ -3438,6 +3511,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -3477,6 +3551,7 @@ 'original_icon': 'mdi:flash', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -3507,6 +3582,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -3537,6 +3613,7 @@ 'original_icon': None, 'original_name': 'Charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -3572,6 +3649,7 @@ 'original_icon': 'mdi:power-plug', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -3602,6 +3680,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -3632,6 +3711,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -3662,6 +3742,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -3690,6 +3771,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -3720,6 +3802,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -3750,6 +3833,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -3778,6 +3862,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -3806,6 +3891,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -3834,6 +3920,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -3862,6 +3949,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', @@ -4129,6 +4217,7 @@ 'original_icon': None, 'original_name': 'Battery', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'vf1aaaaa555777999_battery_level', @@ -4168,6 +4257,7 @@ 'original_icon': 'mdi:flash-off', 'original_name': 'Charge state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charge_state', 'unique_id': 'vf1aaaaa555777999_charge_state', @@ -4198,6 +4288,7 @@ 'original_icon': 'mdi:timer', 'original_name': 'Charging remaining time', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'charging_remaining_time', 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', @@ -4228,6 +4319,7 @@ 'original_icon': None, 'original_name': 'Admissible charging power', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admissible_charging_power', 'unique_id': 'vf1aaaaa555777999_charging_power', @@ -4263,6 +4355,7 @@ 'original_icon': 'mdi:power-plug-off', 'original_name': 'Plug state', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'plug_state', 'unique_id': 'vf1aaaaa555777999_plug_state', @@ -4293,6 +4386,7 @@ 'original_icon': 'mdi:ev-station', 'original_name': 'Battery autonomy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_autonomy', 'unique_id': 'vf1aaaaa555777999_battery_autonomy', @@ -4323,6 +4417,7 @@ 'original_icon': None, 'original_name': 'Battery available energy', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_available_energy', 'unique_id': 'vf1aaaaa555777999_battery_available_energy', @@ -4353,6 +4448,7 @@ 'original_icon': None, 'original_name': 'Battery temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_temperature', 'unique_id': 'vf1aaaaa555777999_battery_temperature', @@ -4381,6 +4477,7 @@ 'original_icon': None, 'original_name': 'Last battery activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'battery_last_activity', 'unique_id': 'vf1aaaaa555777999_battery_last_activity', @@ -4411,6 +4508,7 @@ 'original_icon': 'mdi:sign-direction', 'original_name': 'Mileage', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'mileage', 'unique_id': 'vf1aaaaa555777999_mileage', @@ -4441,6 +4539,7 @@ 'original_icon': None, 'original_name': 'Outside temperature', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'outside_temperature', 'unique_id': 'vf1aaaaa555777999_outside_temperature', @@ -4469,6 +4568,7 @@ 'original_icon': None, 'original_name': 'HVAC SoC threshold', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_soc_threshold', 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', @@ -4497,6 +4597,7 @@ 'original_icon': None, 'original_name': 'Last HVAC activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'hvac_last_activity', 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', @@ -4525,6 +4626,7 @@ 'original_icon': None, 'original_name': 'Last location activity', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'location_last_activity', 'unique_id': 'vf1aaaaa555777999_location_last_activity', @@ -4553,6 +4655,7 @@ 'original_icon': None, 'original_name': 'Remote engine start', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state', 'unique_id': 'vf1aaaaa555777999_res_state', @@ -4581,6 +4684,7 @@ 'original_icon': None, 'original_name': 'Remote engine start code', 'platform': 'renault', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'res_state_code', 'unique_id': 'vf1aaaaa555777999_res_state_code', diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr index 25d8edb15ac..181cf8de17b 100644 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -47,6 +47,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'samsungtv', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'sample-entry-id', diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index b308b5ab3af..1fc8b672c3f 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -53,6 +53,7 @@ 'original_icon': None, 'original_name': 'WAN status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -81,6 +82,7 @@ 'original_icon': None, 'original_name': 'DSL status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_status', @@ -168,6 +170,7 @@ 'original_icon': None, 'original_name': 'WAN status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_status', 'unique_id': 'e4:5d:51:00:11:22_wan_status', @@ -196,6 +199,7 @@ 'original_icon': None, 'original_name': 'FTTH status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'ftth_status', 'unique_id': 'e4:5d:51:00:11:22_ftth_status', diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index f362cfc146f..c216ef6c51d 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -53,6 +53,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_reboot', diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 171a5803ada..29cd99403a2 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -60,6 +60,7 @@ 'original_icon': None, 'original_name': 'Network infrastructure', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'net_infra', 'unique_id': 'e4:5d:51:00:11:22_system_net_infra', @@ -88,6 +89,7 @@ 'original_icon': None, 'original_name': 'Voltage', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_alimvoltage', @@ -116,6 +118,7 @@ 'original_icon': None, 'original_name': 'Temperature', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'e4:5d:51:00:11:22_system_temperature', @@ -152,6 +155,7 @@ 'original_icon': None, 'original_name': 'WAN mode', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'wan_mode', 'unique_id': 'e4:5d:51:00:11:22_wan_mode', @@ -180,6 +184,7 @@ 'original_icon': None, 'original_name': 'DSL line mode', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_linemode', 'unique_id': 'e4:5d:51:00:11:22_dsl_linemode', @@ -208,6 +213,7 @@ 'original_icon': None, 'original_name': 'DSL counter', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_counter', 'unique_id': 'e4:5d:51:00:11:22_dsl_counter', @@ -236,6 +242,7 @@ 'original_icon': None, 'original_name': 'DSL CRC', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_crc', 'unique_id': 'e4:5d:51:00:11:22_dsl_crc', @@ -266,6 +273,7 @@ 'original_icon': None, 'original_name': 'DSL noise down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_down', @@ -296,6 +304,7 @@ 'original_icon': None, 'original_name': 'DSL noise up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_noise_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_noise_up', @@ -326,6 +335,7 @@ 'original_icon': None, 'original_name': 'DSL attenuation down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_down', @@ -356,6 +366,7 @@ 'original_icon': None, 'original_name': 'DSL attenuation up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_attenuation_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_attenuation_up', @@ -386,6 +397,7 @@ 'original_icon': None, 'original_name': 'DSL rate down', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_down', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_down', @@ -416,6 +428,7 @@ 'original_icon': None, 'original_name': 'DSL rate up', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_rate_up', 'unique_id': 'e4:5d:51:00:11:22_dsl_rate_up', @@ -453,6 +466,7 @@ 'original_icon': None, 'original_name': 'DSL line status', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_line_status', 'unique_id': 'e4:5d:51:00:11:22_dsl_line_status', @@ -494,6 +508,7 @@ 'original_icon': None, 'original_name': 'DSL training', 'platform': 'sfr_box', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'dsl_training', 'unique_id': 'e4:5d:51:00:11:22_dsl_training', diff --git a/tests/components/tplink_omada/snapshots/test_switch.ambr b/tests/components/tplink_omada/snapshots/test_switch.ambr index b48f6a5e749..99f49e44bf2 100644 --- a/tests/components/tplink_omada/snapshots/test_switch.ambr +++ b/tests/components/tplink_omada/snapshots/test_switch.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 1 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000001_poe', @@ -79,6 +80,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 6 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000006_poe', @@ -122,6 +124,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 7 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000007_poe', @@ -165,6 +168,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 8 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000008_poe', @@ -208,6 +212,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 2 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000002_poe', @@ -251,6 +256,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 3 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000003_poe', @@ -294,6 +300,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 4 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000004_poe', @@ -337,6 +344,7 @@ 'original_icon': 'mdi:ethernet', 'original_name': 'Port 5 PoE', 'platform': 'tplink_omada', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '54-AF-97-00-00-01_000000000000000000000005_poe', diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index d004084e063..6403bd83255 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -68,6 +68,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': None, 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '12345', diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index 367da49c7f6..f0e9578ff23 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -37,6 +37,7 @@ 'original_icon': 'mdi:pine-tree', 'original_name': 'Christmas tree pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'christmas_tree_pickup', 'unique_id': 'twentemilieu_12345_tree', @@ -108,6 +109,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Non-recyclable waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'non_recyclable_waste_pickup', 'unique_id': 'twentemilieu_12345_Non-recyclable', @@ -179,6 +181,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Organic waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'organic_waste_pickup', 'unique_id': 'twentemilieu_12345_Organic', @@ -250,6 +253,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Packages waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'packages_waste_pickup', 'unique_id': 'twentemilieu_12345_Plastic', @@ -321,6 +325,7 @@ 'original_icon': 'mdi:delete-empty', 'original_name': 'Paper waste pickup', 'platform': 'twentemilieu', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'paper_waste_pickup', 'unique_id': 'twentemilieu_12345_Paper', diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 539ba640d80..4381cf30647 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'uptime', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unit_of_measurement': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index fa1a7a7b332..8dbefd41794 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -58,6 +58,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'air-purifier', @@ -140,6 +141,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55', @@ -229,6 +231,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '400s-purifier', @@ -319,6 +322,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': , 'translation_key': None, 'unique_id': '600s-purifier', diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 67940603d41..4c33d11564a 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -189,6 +189,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-bulb', @@ -270,6 +271,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'dimmable-switch', @@ -406,6 +408,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'tunable-bulb', diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 06198bca145..7cda1cd0649 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -55,6 +55,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'air-purifier-filter-life', @@ -83,6 +84,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': 'air-purifier-air-quality', @@ -172,6 +174,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-filter-life', @@ -249,6 +252,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '400s-purifier-filter-life', @@ -277,6 +281,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '400s-purifier-air-quality', @@ -307,6 +312,7 @@ 'original_icon': None, 'original_name': 'PM2.5', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '400s-purifier-pm25', @@ -411,6 +417,7 @@ 'original_icon': None, 'original_name': 'Filter lifetime', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'filter_life', 'unique_id': '600s-purifier-filter-life', @@ -439,6 +446,7 @@ 'original_icon': None, 'original_name': 'Air quality', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'air_quality', 'unique_id': '600s-purifier-air-quality', @@ -469,6 +477,7 @@ 'original_icon': None, 'original_name': 'PM2.5', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': '600s-purifier-pm25', @@ -655,6 +664,7 @@ 'original_icon': None, 'original_name': 'Current power', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_power', 'unique_id': 'outlet-power', @@ -685,6 +695,7 @@ 'original_icon': None, 'original_name': 'Energy use today', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_today', 'unique_id': 'outlet-energy', @@ -715,6 +726,7 @@ 'original_icon': None, 'original_name': 'Energy use weekly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_week', 'unique_id': 'outlet-energy-weekly', @@ -745,6 +757,7 @@ 'original_icon': None, 'original_name': 'Energy use monthly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_month', 'unique_id': 'outlet-energy-monthly', @@ -775,6 +788,7 @@ 'original_icon': None, 'original_name': 'Energy use yearly', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'energy_year', 'unique_id': 'outlet-energy-yearly', @@ -805,6 +819,7 @@ 'original_icon': None, 'original_name': 'Current voltage', 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'current_voltage', 'unique_id': 'outlet-voltage', diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index cfe9d66a2ed..95dcb24ded6 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -267,6 +267,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'outlet', @@ -373,6 +374,7 @@ 'original_icon': None, 'original_name': None, 'platform': 'vesync', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'switch', diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 464af13c7c8..519d5894072 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': 'mdi:account-star', 'original_name': 'Admin', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'admin', 'unique_id': 'home-assistant.io_admin', @@ -106,6 +107,7 @@ 'original_icon': None, 'original_name': 'Created', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'creation_date', 'unique_id': 'home-assistant.io_creation_date', @@ -181,6 +183,7 @@ 'original_icon': 'mdi:calendar-clock', 'original_name': 'Days until expiration', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'days_until_expiration', 'unique_id': 'home-assistant.io_days_until_expiration', @@ -251,6 +254,7 @@ 'original_icon': None, 'original_name': 'Expires', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'expiration_date', 'unique_id': 'home-assistant.io_expiration_date', @@ -321,6 +325,7 @@ 'original_icon': None, 'original_name': 'Last updated', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', @@ -391,6 +396,7 @@ 'original_icon': 'mdi:account', 'original_name': 'Owner', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'owner', 'unique_id': 'home-assistant.io_owner', @@ -461,6 +467,7 @@ 'original_icon': 'mdi:account-edit', 'original_name': 'Registrant', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'registrant', 'unique_id': 'home-assistant.io_registrant', @@ -531,6 +538,7 @@ 'original_icon': 'mdi:store', 'original_name': 'Registrar', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'registrar', 'unique_id': 'home-assistant.io_registrar', @@ -601,6 +609,7 @@ 'original_icon': 'mdi:store', 'original_name': 'Reseller', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'reseller', 'unique_id': 'home-assistant.io_reseller', @@ -671,6 +680,7 @@ 'original_icon': None, 'original_name': 'Last updated', 'platform': 'whois', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'last_updated', 'unique_id': 'home-assistant.io_last_updated', diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr index 7520ea7a6a6..bcf9d7a4cdb 100644 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Firmware', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_update', diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index da487b49489..b11befe3832 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -36,6 +36,7 @@ 'original_icon': None, 'original_name': 'Restart', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_restart', diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 96b465616c4..509a8860611 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -44,6 +44,7 @@ 'original_icon': None, 'original_name': 'Segment 1 Intensity', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_intensity_1', @@ -127,6 +128,7 @@ 'original_icon': 'mdi:speedometer', 'original_name': 'Segment 1 Speed', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_speed_1', diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 9cfc6c6e3fe..d52c6a10ddd 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -47,6 +47,7 @@ 'original_icon': 'mdi:theater', 'original_name': 'Live override', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'live_override', 'unique_id': 'aabbccddeeff_live_override', @@ -226,6 +227,7 @@ 'original_icon': 'mdi:palette-outline', 'original_name': 'Segment 1 color palette', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_palette_1', @@ -309,6 +311,7 @@ 'original_icon': 'mdi:play-speed', 'original_name': 'Playlist', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'playlist', 'unique_id': 'aabbccddee11_playlist', @@ -392,6 +395,7 @@ 'original_icon': 'mdi:playlist-play', 'original_name': 'Preset', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'preset', 'unique_id': 'aabbccddee11_preset', diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 1434d2b2b2d..52f1e9562e2 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -39,6 +39,7 @@ 'original_icon': 'mdi:weather-night', 'original_name': 'Nightlight', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', @@ -113,6 +114,7 @@ 'original_icon': 'mdi:swap-horizontal-bold', 'original_name': 'Reverse', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': None, 'unique_id': 'aabbccddeeff_reverse_0', @@ -188,6 +190,7 @@ 'original_icon': 'mdi:download-network-outline', 'original_name': 'Sync receive', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', @@ -263,6 +266,7 @@ 'original_icon': 'mdi:upload-network-outline', 'original_name': 'Sync send', 'platform': 'wled', + 'previous_unique_id': None, 'supported_features': 0, 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 4bf03b4d39b..95558e9c73d 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -665,6 +665,7 @@ async def test_update_entity_unique_id(entity_registry: er.EntityRegistry) -> No ) assert updated_entry != entry assert updated_entry.unique_id == new_unique_id + assert updated_entry.previous_unique_id == "5678" assert mock_schedule_save.call_count == 1 assert entity_registry.async_get_entity_id("light", "hue", "5678") is None From 10fd26df4be168b5373a7ef3e095bf71c9c04900 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 16 Oct 2023 21:42:02 -1000 Subject: [PATCH 498/968] Preserve HomeKit Accessory ID when entity unique id changes (#102123) --- .../components/homekit/aidmanager.py | 27 ++++++++++----- .../components/homekit/type_triggers.py | 4 ++- tests/components/homekit/test_aidmanager.py | 34 +++++++++++++++++-- tests/components/homekit/test_homekit.py | 2 ++ 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homekit/aidmanager.py b/homeassistant/components/homekit/aidmanager.py index 0deb4500197..43beaaa8dc6 100644 --- a/homeassistant/components/homekit/aidmanager.py +++ b/homeassistant/components/homekit/aidmanager.py @@ -33,9 +33,9 @@ AID_MIN = 2 AID_MAX = 18446744073709551615 -def get_system_unique_id(entity: er.RegistryEntry) -> str: +def get_system_unique_id(entity: er.RegistryEntry, entity_unique_id: str) -> str: """Determine the system wide unique_id for an entity.""" - return f"{entity.platform}.{entity.domain}.{entity.unique_id}" + return f"{entity.platform}.{entity.domain}.{entity_unique_id}" def _generate_aids(unique_id: str | None, entity_id: str) -> Generator[int, None, None]: @@ -72,31 +72,42 @@ class AccessoryAidStorage: self.allocated_aids: set[int] = set() self._entry_id = entry_id self.store: Store | None = None - self._entity_registry: er.EntityRegistry | None = None + self._entity_registry = er.async_get(hass) async def async_initialize(self) -> None: """Load the latest AID data.""" - self._entity_registry = er.async_get(self.hass) aidstore = get_aid_storage_filename_for_entry_id(self._entry_id) self.store = Store(self.hass, AID_MANAGER_STORAGE_VERSION, aidstore) if not (raw_storage := await self.store.async_load()): # There is no data about aid allocations yet return - assert isinstance(raw_storage, dict) self.allocations = raw_storage.get(ALLOCATIONS_KEY, {}) self.allocated_aids = set(self.allocations.values()) def get_or_allocate_aid_for_entity_id(self, entity_id: str) -> int: """Generate a stable aid for an entity id.""" - assert self._entity_registry is not None - if not (entity := self._entity_registry.async_get(entity_id)): + if not (entry := self._entity_registry.async_get(entity_id)): return self.get_or_allocate_aid(None, entity_id) - sys_unique_id = get_system_unique_id(entity) + sys_unique_id = get_system_unique_id(entry, entry.unique_id) + self._migrate_unique_id_aid_assignment_if_needed(sys_unique_id, entry) return self.get_or_allocate_aid(sys_unique_id, entity_id) + def _migrate_unique_id_aid_assignment_if_needed( + self, sys_unique_id: str, entry: er.RegistryEntry + ) -> None: + """Migrate the unique id aid assignment if its changed.""" + if sys_unique_id in self.allocations or not ( + previous_unique_id := entry.previous_unique_id + ): + return + old_sys_unique_id = get_system_unique_id(entry, previous_unique_id) + if aid := self.allocations.pop(old_sys_unique_id, None): + self.allocations[sys_unique_id] = aid + self.async_schedule_save() + def get_or_allocate_aid(self, unique_id: str | None, entity_id: str) -> int: """Allocate (and return) a new aid for an accessory.""" if unique_id and unique_id in self.allocations: diff --git a/homeassistant/components/homekit/type_triggers.py b/homeassistant/components/homekit/type_triggers.py index be8db07d517..8cd01638679 100644 --- a/homeassistant/components/homekit/type_triggers.py +++ b/homeassistant/components/homekit/type_triggers.py @@ -51,7 +51,9 @@ class DeviceTriggerAccessory(HomeAccessory): if (entity_id_or_uuid := trigger.get("entity_id")) and ( entry := ent_reg.async_get(entity_id_or_uuid) ): - unique_id += f"-entity_unique_id:{get_system_unique_id(entry)}" + unique_id += ( + f"-entity_unique_id:{get_system_unique_id(entry, entry.unique_id)}" + ) entity_id = entry.entity_id trigger_name_parts = [] if entity_id and (state := self.hass.states.get(entity_id)): diff --git a/tests/components/homekit/test_aidmanager.py b/tests/components/homekit/test_aidmanager.py index 18e654cb4ed..447cdc99a57 100644 --- a/tests/components/homekit/test_aidmanager.py +++ b/tests/components/homekit/test_aidmanager.py @@ -66,9 +66,9 @@ async def test_aid_generation( == 1751603975 ) - aid_storage.delete_aid(get_system_unique_id(light_ent)) - aid_storage.delete_aid(get_system_unique_id(light_ent2)) - aid_storage.delete_aid(get_system_unique_id(remote_ent)) + aid_storage.delete_aid(get_system_unique_id(light_ent, light_ent.unique_id)) + aid_storage.delete_aid(get_system_unique_id(light_ent2, light_ent2.unique_id)) + aid_storage.delete_aid(get_system_unique_id(remote_ent, remote_ent.unique_id)) aid_storage.delete_aid("non-existent-one") for _ in range(0, 2): @@ -618,3 +618,31 @@ async def test_aid_generation_no_unique_ids_handles_collision( aid_storage_path = hass.config.path(STORAGE_DIR, aidstore) if await hass.async_add_executor_job(os.path.exists, aid_storage_path): await hass.async_add_executor_job(os.unlink, aid_storage_path) + + +async def test_handle_unique_id_change( + hass: HomeAssistant, +) -> None: + """Test handling unique id changes.""" + entity_registry = er.async_get(hass) + light = entity_registry.async_get_or_create("light", "demo", "old_unique") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homekit.aidmanager.AccessoryAidStorage.async_schedule_save" + ): + aid_storage = AccessoryAidStorage(hass, config_entry) + await aid_storage.async_initialize() + + original_aid = aid_storage.get_or_allocate_aid_for_entity_id(light.entity_id) + assert aid_storage.allocations == {"demo.light.old_unique": 4202023227} + + entity_registry.async_update_entity(light.entity_id, new_unique_id="new_unique") + await hass.async_block_till_done() + + aid = aid_storage.get_or_allocate_aid_for_entity_id(light.entity_id) + assert aid == original_aid + + # Verify that the old unique id is removed from the allocations + # and that the new unique id assumes the old aid + assert aid_storage.allocations == {"demo.light.new_unique": 4202023227} diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 5c517ac9cb9..158efa477d4 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -841,6 +841,7 @@ async def test_homekit_start_with_a_device( await async_init_entry(hass, entry) homekit = _mock_homekit(hass, entry, HOMEKIT_MODE_BRIDGE, None, devices=[device_id]) homekit.driver = hk_driver + homekit.aid_storage = MagicMock() with patch(f"{PATH_HOMEKIT}.get_accessory", side_effect=Exception), patch( f"{PATH_HOMEKIT}.async_show_setup_message" @@ -868,6 +869,7 @@ async def test_homekit_stop(hass: HomeAssistant) -> None: homekit.driver.async_stop = AsyncMock() homekit.bridge = Mock() homekit.bridge.accessories = {} + homekit.aid_storage = MagicMock() assert homekit.status == STATUS_READY await homekit.async_stop() From ab29c796da44392eb61527e7e8c8e976d5596869 Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 17 Oct 2023 01:35:32 -0700 Subject: [PATCH 499/968] Bump opower to 0.0.36 (#102150) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 71fd841d0fc..02c73238ef9 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.35"] + "requirements": ["opower==0.0.36"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2cf921d42cd..c1ec8ece5ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1392,7 +1392,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.35 +opower==0.0.36 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1a0fb22753b..1810e135cbe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1070,7 +1070,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.35 +opower==0.0.36 # homeassistant.components.oralb oralb-ble==0.17.6 From fc09d87c3c992c620116801652f2c557dd817582 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 16:38:19 +0200 Subject: [PATCH 500/968] Fix menu in mysensors config flow (#102169) --- homeassistant/components/mysensors/config_flow.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index b3c3d11f279..8011bfcb155 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -131,6 +131,12 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, str] | None = None ) -> FlowResult: """Create a config entry from frontend user input.""" + return await self.async_step_select_gateway_type() + + async def async_step_select_gateway_type( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Show the select gateway type menu.""" return self.async_show_menu( step_id="select_gateway_type", menu_options=["gw_serial", "gw_tcp", "gw_mqtt"], From 928086a9e59a2ad29a20bed6bf9c3518e85d70d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 16:43:10 +0200 Subject: [PATCH 501/968] Fix menu in hassio repair flow (#102162) --- homeassistant/components/hassio/repairs.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index d5e26d4670f..8337405641c 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -86,15 +86,21 @@ class SupervisorIssueRepairFlow(RepairsFlow): ) if len(self.issue.suggestions) > 1: - return self.async_show_menu( - step_id="fix_menu", - menu_options=[suggestion.key for suggestion in self.issue.suggestions], - description_placeholders=self.description_placeholders, - ) + return await self.async_step_fix_menu() # Always show a form for one suggestion to explain to user what's happening return self._async_form_for_suggestion(self.issue.suggestions[0]) + async def async_step_fix_menu(self, _: None = None) -> FlowResult: + """Show the fix menu.""" + assert self.issue + + return self.async_show_menu( + step_id="fix_menu", + menu_options=[suggestion.key for suggestion in self.issue.suggestions], + description_placeholders=self.description_placeholders, + ) + async def _async_step_apply_suggestion( self, suggestion: Suggestion, confirmed: bool = False ) -> FlowResult: From 7cd376574ed9ca848138a8effb4f8308128d0270 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 17 Oct 2023 16:49:05 +0200 Subject: [PATCH 502/968] Reach full init test coverage in Minecraft Server (#102013) * Add full init test coverage * Fix patch location * Use contant for test unique ID --- .coveragerc | 1 - tests/components/minecraft_server/conftest.py | 6 +- tests/components/minecraft_server/const.py | 30 ++- .../components/minecraft_server/test_init.py | 216 ++++++++++++------ 4 files changed, 171 insertions(+), 82 deletions(-) diff --git a/.coveragerc b/.coveragerc index 74729b059f1..e12222594ac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -744,7 +744,6 @@ omit = homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py - homeassistant/components/minecraft_server/__init__.py homeassistant/components/minecraft_server/api.py homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minecraft_server/coordinator.py diff --git a/tests/components/minecraft_server/conftest.py b/tests/components/minecraft_server/conftest.py index 8e166fbb2da..b118b15d08a 100644 --- a/tests/components/minecraft_server/conftest.py +++ b/tests/components/minecraft_server/conftest.py @@ -5,7 +5,7 @@ from homeassistant.components.minecraft_server.api import MinecraftServerType from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE -from .const import TEST_ADDRESS +from .const import TEST_ADDRESS, TEST_CONFIG_ENTRY_ID from tests.common import MockConfigEntry @@ -16,7 +16,7 @@ def java_mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, unique_id=None, - entry_id="01234567890123456789012345678901", + entry_id=TEST_CONFIG_ENTRY_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, @@ -32,7 +32,7 @@ def bedrock_mock_config_entry() -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, unique_id=None, - entry_id="01234567890123456789012345678901", + entry_id=TEST_CONFIG_ENTRY_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py index f299dd8efb8..56be9132f19 100644 --- a/tests/components/minecraft_server/const.py +++ b/tests/components/minecraft_server/const.py @@ -7,27 +7,33 @@ from mcstatus.status_response import ( JavaStatusPlayers, JavaStatusResponse, JavaStatusVersion, + RawJavaResponse, + RawJavaResponsePlayer, + RawJavaResponsePlayers, + RawJavaResponseVersion, ) from homeassistant.components.minecraft_server.api import MinecraftServerData +TEST_CONFIG_ENTRY_ID: str = "01234567890123456789012345678901" TEST_HOST = "mc.dummyserver.com" TEST_PORT = 25566 TEST_ADDRESS = f"{TEST_HOST}:{TEST_PORT}" -TEST_JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy MOTD"}, - "version": {"name": "Dummy Version", "protocol": 123}, - "players": { - "online": 3, - "max": 10, - "sample": [ - {"name": "Player 1", "id": "1"}, - {"name": "Player 2", "id": "2"}, - {"name": "Player 3", "id": "3"}, +TEST_JAVA_STATUS_RESPONSE_RAW = RawJavaResponse( + description="Dummy MOTD", + players=RawJavaResponsePlayers( + online=3, + max=10, + sample=[ + RawJavaResponsePlayer(id="1", name="Player 1"), + RawJavaResponsePlayer(id="2", name="Player 2"), + RawJavaResponsePlayer(id="3", name="Player 3"), ], - }, -} + ), + version=RawJavaResponseVersion(name="Dummy Version", protocol=123), + favicon="Dummy Icon", +) TEST_JAVA_STATUS_RESPONSE = JavaStatusResponse( raw=TEST_JAVA_STATUS_RESPONSE_RAW, diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index cc9730ef3df..09e411f0b62 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -1,15 +1,24 @@ """Tests for the Minecraft Server integration.""" from unittest.mock import patch +from mcstatus import JavaServer +import pytest + from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.minecraft_server.api import MinecraftServerAddressError from homeassistant.components.minecraft_server.const import DEFAULT_NAME, DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ADDRESS, CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from .const import TEST_ADDRESS, TEST_HOST, TEST_JAVA_DATA, TEST_PORT +from .const import ( + TEST_ADDRESS, + TEST_CONFIG_ENTRY_ID, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) from tests.common import MockConfigEntry @@ -27,11 +36,13 @@ SENSOR_KEYS = [ BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} -def create_v1_mock_config_entry(hass: HomeAssistant) -> int: - """Create mock config entry.""" - config_entry_v1 = MockConfigEntry( +@pytest.fixture +def v1_mock_config_entry() -> MockConfigEntry: + """Create mock config entry with version 1.""" + return MockConfigEntry( domain=DOMAIN, unique_id=TEST_UNIQUE_ID, + entry_id=TEST_CONFIG_ENTRY_ID, data={ CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST, @@ -39,14 +50,10 @@ def create_v1_mock_config_entry(hass: HomeAssistant) -> int: }, version=1, ) - config_entry_id = config_entry_v1.entry_id - config_entry_v1.add_to_hass(hass) - - return config_entry_id -def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> int: - """Create mock device entry.""" +def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: str) -> str: + """Create mock device entry with version 1.""" device_registry = dr.async_get(hass) device_entry_v1 = device_registry.async_get_or_create( config_entry_id=config_entry_id, @@ -61,9 +68,9 @@ def create_v1_mock_device_entry(hass: HomeAssistant, config_entry_id: int) -> in def create_v1_mock_sensor_entity_entries( - hass: HomeAssistant, config_entry_id: int, device_entry_id: int + hass: HomeAssistant, config_entry_id: str, device_entry_id: str ) -> list[dict]: - """Create mock sensor entity entries.""" + """Create mock sensor entity entries with version 1.""" sensor_entity_id_key_mapping_list = [] config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) @@ -86,9 +93,9 @@ def create_v1_mock_sensor_entity_entries( def create_v1_mock_binary_sensor_entity_entry( - hass: HomeAssistant, config_entry_id: int, device_entry_id: int + hass: HomeAssistant, config_entry_id: str, device_entry_id: str ) -> dict: - """Create mock binary sensor entity entry.""" + """Create mock binary sensor entity entry with version 1.""" config_entry = hass.config_entries.async_get_entry(config_entry_id) entity_registry = er.async_get(hass) entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" @@ -108,53 +115,121 @@ def create_v1_mock_binary_sensor_entity_entry( return binary_sensor_entity_id_key_mapping -async def test_entry_migration(hass: HomeAssistant) -> None: +async def test_setup_and_unload_entry( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test successful entry setup and unload.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(java_mock_config_entry.entry_id) + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(java_mock_config_entry.entry_id) + await hass.async_block_till_done() + assert not hass.data.get(DOMAIN) + assert java_mock_config_entry.state == ConfigEntryState.NOT_LOADED + + +async def test_setup_entry_failure( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test failed entry setup.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + side_effect=ValueError, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_setup_entry_not_ready( + hass: HomeAssistant, java_mock_config_entry: MockConfigEntry +) -> None: + """Test entry setup not ready.""" + java_mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), + ), patch( + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=OSError, + ): + assert not await hass.config_entries.async_setup( + java_mock_config_entry.entry_id + ) + + await hass.async_block_till_done() + assert java_mock_config_entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_entry_migration( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test entry migration from version 1 to 3, where host and port is required for the connection to the server.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) sensor_entity_id_key_mapping_list = create_v1_mock_sensor_entity_entries( - hass, config_entry_id, device_entry_id + hass, v1_mock_config_entry.entry_id, device_entry_id ) binary_sensor_entity_id_key_mapping = create_v1_mock_binary_sensor_entity_entry( - hass, config_entry_id, device_entry_id + hass, v1_mock_config_entry.entry_id, device_entry_id ) # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + "homeassistant.components.minecraft_server.api.JavaServer.lookup", side_effect=[ - MinecraftServerAddressError, # async_migrate_entry - None, # async_migrate_entry - None, # async_setup_entry + ValueError, # async_migrate_entry + JavaServer(host=TEST_HOST, port=TEST_PORT), # async_migrate_entry + JavaServer(host=TEST_HOST, port=TEST_PORT), # async_setup_entry ], - return_value=None, ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", - return_value=TEST_JAVA_DATA, + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, ): - assert await hass.config_entries.async_setup(config_entry_id) + assert await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() + migrated_config_entry = v1_mock_config_entry + # Test migrated config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.unique_id is None - assert config_entry.data == { + assert migrated_config_entry.unique_id is None + assert migrated_config_entry.data == { CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_ADDRESS, } - - assert config_entry.version == 3 + assert migrated_config_entry.version == 3 + assert migrated_config_entry.state == ConfigEntryState.LOADED # Test migrated device entry. device_registry = dr.async_get(hass) device_entry = device_registry.async_get(device_entry_id) - assert device_entry.identifiers == {(DOMAIN, config_entry_id)} + assert device_entry.identifiers == {(DOMAIN, migrated_config_entry.entry_id)} # Test migrated sensor entity entries. entity_registry = er.async_get(hass) for mapping in sensor_entity_id_key_mapping_list: entity_entry = entity_registry.async_get(mapping["entity_id"]) - assert entity_entry.unique_id == f"{config_entry_id}-{mapping['key']}" + assert ( + entity_entry.unique_id + == f"{migrated_config_entry.entry_id}-{mapping['key']}" + ) # Test migrated binary sensor entity entry. entity_entry = entity_registry.async_get( @@ -162,61 +237,70 @@ async def test_entry_migration(hass: HomeAssistant) -> None: ) assert ( entity_entry.unique_id - == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + == f"{migrated_config_entry.entry_id}-{binary_sensor_entity_id_key_mapping['key']}" ) -async def test_entry_migration_host_only(hass: HomeAssistant) -> None: +async def test_entry_migration_host_only( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test entry migration from version 1 to 3, where host alone is sufficient for the connection to the server.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) - create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) - create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) + create_v1_mock_sensor_entity_entries( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) + create_v1_mock_binary_sensor_entity_entry( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", - side_effect=[ - None, # async_migrate_entry - None, # async_setup_entry - ], - return_value=None, + "homeassistant.components.minecraft_server.api.JavaServer.lookup", + return_value=JavaServer(host=TEST_HOST, port=TEST_PORT), ), patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.async_get_data", - return_value=TEST_JAVA_DATA, + "homeassistant.components.minecraft_server.api.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, ): - assert await hass.config_entries.async_setup(config_entry_id) + assert await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() # Test migrated config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.unique_id is None - assert config_entry.data == { + assert v1_mock_config_entry.unique_id is None + assert v1_mock_config_entry.data == { CONF_NAME: DEFAULT_NAME, CONF_ADDRESS: TEST_HOST, } - assert config_entry.version == 3 + assert v1_mock_config_entry.version == 3 + assert v1_mock_config_entry.state == ConfigEntryState.LOADED -async def test_entry_migration_v3_failure(hass: HomeAssistant) -> None: +async def test_entry_migration_v3_failure( + hass: HomeAssistant, v1_mock_config_entry: MockConfigEntry +) -> None: """Test failed entry migration from version 2 to 3.""" - config_entry_id = create_v1_mock_config_entry(hass) - device_entry_id = create_v1_mock_device_entry(hass, config_entry_id) - create_v1_mock_sensor_entity_entries(hass, config_entry_id, device_entry_id) - create_v1_mock_binary_sensor_entity_entry(hass, config_entry_id, device_entry_id) + v1_mock_config_entry.add_to_hass(hass) + + device_entry_id = create_v1_mock_device_entry(hass, v1_mock_config_entry.entry_id) + create_v1_mock_sensor_entity_entries( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) + create_v1_mock_binary_sensor_entity_entry( + hass, v1_mock_config_entry.entry_id, device_entry_id + ) # Trigger migration. with patch( - "homeassistant.components.minecraft_server.api.MinecraftServer.__init__", + "homeassistant.components.minecraft_server.api.JavaServer.lookup", side_effect=[ - MinecraftServerAddressError, # async_migrate_entry - MinecraftServerAddressError, # async_migrate_entry + ValueError, # async_migrate_entry + ValueError, # async_migrate_entry ], - return_value=None, ): - assert not await hass.config_entries.async_setup(config_entry_id) + assert not await hass.config_entries.async_setup(v1_mock_config_entry.entry_id) await hass.async_block_till_done() # Test config entry. - config_entry = hass.config_entries.async_get_entry(config_entry_id) - assert config_entry.version == 2 + assert v1_mock_config_entry.version == 2 + assert v1_mock_config_entry.state == ConfigEntryState.MIGRATION_ERROR From 52063537d74ead02d4f9340a438808bc5963b8f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 16:53:52 +0200 Subject: [PATCH 503/968] Fix menu in homeassistant_hardware config flow (#102164) --- .../homeassistant_hardware/silabs_multiprotocol_addon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index 40cf1e18b0e..7884d3f5617 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -588,9 +588,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow, ABC): serial_device = (await self._async_serial_port_settings()).device if addon_info.options.get(CONF_ADDON_DEVICE) != serial_device: return await self.async_step_addon_installed_other_device() - return await self.async_step_show_addon_menu() + return await self.async_step_addon_menu() - async def async_step_show_addon_menu( + async def async_step_addon_menu( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Show menu options for the addon.""" From f7c1dd2f79d33e7efc1754d6604f2675d17e92fe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 17:08:48 +0200 Subject: [PATCH 504/968] Fix menu in here_travel_time config flow (#102163) --- .../here_travel_time/config_flow.py | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/here_travel_time/config_flow.py b/homeassistant/components/here_travel_time/config_flow.py index 48a500f17f0..3db4a841d53 100644 --- a/homeassistant/components/here_travel_time/config_flow.py +++ b/homeassistant/components/here_travel_time/config_flow.py @@ -124,14 +124,18 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" if not errors: self._config = user_input - return self.async_show_menu( - step_id="origin_menu", - menu_options=["origin_coordinates", "origin_entity"], - ) + return await self.async_step_origin_menu() return self.async_show_form( step_id="user", data_schema=get_user_step_schema(user_input), errors=errors ) + async def async_step_origin_menu(self, _: None = None) -> FlowResult: + """Show the origin menu.""" + return self.async_show_menu( + step_id="origin_menu", + menu_options=["origin_coordinates", "origin_entity"], + ) + async def async_step_origin_coordinates( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -141,10 +145,7 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._config[CONF_ORIGIN_LONGITUDE] = user_input[CONF_ORIGIN][ CONF_LONGITUDE ] - return self.async_show_menu( - step_id="destination_menu", - menu_options=["destination_coordinates", "destination_entity"], - ) + return await self.async_step_destination_menu() schema = vol.Schema( { vol.Required( @@ -158,16 +159,20 @@ class HERETravelTimeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="origin_coordinates", data_schema=schema) + async def async_step_destination_menu(self, _: None = None) -> FlowResult: + """Show the destination menu.""" + return self.async_show_menu( + step_id="destination_menu", + menu_options=["destination_coordinates", "destination_entity"], + ) + async def async_step_origin_entity( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure origin by using an entity.""" if user_input is not None: self._config[CONF_ORIGIN_ENTITY_ID] = user_input[CONF_ORIGIN_ENTITY_ID] - return self.async_show_menu( - step_id="destination_menu", - menu_options=["destination_coordinates", "destination_entity"], - ) + return await self.async_step_destination_menu() schema = vol.Schema({vol.Required(CONF_ORIGIN_ENTITY_ID): EntitySelector()}) return self.async_show_form(step_id="origin_entity", data_schema=schema) @@ -237,10 +242,7 @@ class HERETravelTimeOptionsFlow(config_entries.OptionsFlow): """Manage the HERE Travel Time options.""" if user_input is not None: self._config = user_input - return self.async_show_menu( - step_id="time_menu", - menu_options=["departure_time", "arrival_time", "no_time"], - ) + return await self.async_step_time_menu() schema = vol.Schema( { @@ -255,6 +257,13 @@ class HERETravelTimeOptionsFlow(config_entries.OptionsFlow): return self.async_show_form(step_id="init", data_schema=schema) + async def async_step_time_menu(self, _: None = None) -> FlowResult: + """Show the time menu.""" + return self.async_show_menu( + step_id="time_menu", + menu_options=["departure_time", "arrival_time", "no_time"], + ) + async def async_step_no_time( self, user_input: dict[str, Any] | None = None ) -> FlowResult: From af66bc5e3ad62c76b51d87afb85c8f43f263b408 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 17:15:57 +0200 Subject: [PATCH 505/968] Fix menu in homeassistant_yellow config flow (#102166) --- .../components/homeassistant_yellow/config_flow.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index 667b8f3d97a..7681d6d3847 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -63,6 +63,10 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle logic when on Supervisor host.""" + return await self.async_step_main_menu() + + async def async_step_main_menu(self, _: None = None) -> FlowResult: + """Show the main menu.""" return self.async_show_menu( step_id="main_menu", menu_options=[ @@ -85,7 +89,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl except (aiohttp.ClientError, TimeoutError, HassioAPIError) as err: _LOGGER.warning("Failed to write hardware settings", exc_info=err) return self.async_abort(reason="write_hw_settings_error") - return await self.async_step_confirm_reboot() + return await self.async_step_reboot_menu() try: async with asyncio.timeout(10): @@ -102,7 +106,7 @@ class HomeAssistantYellowOptionsFlow(silabs_multiprotocol_addon.OptionsFlowHandl return self.async_show_form(step_id="hardware_settings", data_schema=schema) - async def async_step_confirm_reboot( + async def async_step_reboot_menu( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reboot host.""" From 474f4329bc7769ae2cda787309897ba34f7b99fe Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Tue, 17 Oct 2023 10:57:08 -0500 Subject: [PATCH 506/968] Don't warn about unknown pipeline events in ESPHome (#102174) Don't warn about unknown events (debug) --- homeassistant/components/esphome/voice_assistant.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index de6313f45aa..26c0780d735 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -163,7 +163,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): try: event_type = _VOICE_ASSISTANT_EVENT_TYPES.from_hass(event.type) except KeyError: - _LOGGER.warning("Received unknown pipeline event type: %s", event.type) + _LOGGER.debug("Received unknown pipeline event type: %s", event.type) return data_to_send = None From 44a5a2dc06ba45966a59d5d73636a7e36267bb26 Mon Sep 17 00:00:00 2001 From: iain MacDonnell Date: Tue, 17 Oct 2023 16:59:15 +0100 Subject: [PATCH 507/968] Explicitly set entity name for VenstarSensor (#102158) VenstarSensor entity name Set entity name using _attr_name instead of a name property, to avoid warnings about name being implicitly set to None. --- homeassistant/components/venstar/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/venstar/sensor.py b/homeassistant/components/venstar/sensor.py index 2d919bbc1bc..7125dfd4540 100644 --- a/homeassistant/components/venstar/sensor.py +++ b/homeassistant/components/venstar/sensor.py @@ -146,6 +146,7 @@ class VenstarSensor(VenstarEntity, SensorEntity): super().__init__(coordinator, config) self.entity_description = entity_description self.sensor_name = sensor_name + self._attr_name = entity_description.name_fn(sensor_name) self._config = config @property @@ -153,11 +154,6 @@ class VenstarSensor(VenstarEntity, SensorEntity): """Return the unique id.""" return f"{self._config.entry_id}_{self.sensor_name.replace(' ', '_')}_{self.entity_description.key}" - @property - def name(self): - """Return the name of the device.""" - return self.entity_description.name_fn(self.sensor_name) - @property def native_value(self) -> int: """Return state of the sensor.""" From 4ae5757bc175307545e45519cb64a1d24b9f763f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Oct 2023 14:19:10 -0400 Subject: [PATCH 508/968] Add some entity categories to Reolink (#102141) --- homeassistant/components/reolink/select.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 84d39b3d8e2..fd42e69268d 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -78,6 +78,7 @@ SELECT_ENTITIES = ( key="auto_quick_reply_message", translation_key="auto_quick_reply_message", icon="mdi:message-reply-text-outline", + entity_category=EntityCategory.CONFIG, get_options=lambda api, ch: list(api.quick_reply_dict(ch).values()), supported=lambda api, ch: api.supported(ch, "quick_reply"), value=lambda api, ch: api.quick_reply_dict(ch)[api.quick_reply_file(ch)], From 29e8814d1bf85fe3840b38030b7b736f5f7f12eb Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 17 Oct 2023 21:59:49 +0200 Subject: [PATCH 509/968] Add translation entiry support (zha) (#101909) * zha: Add translation to binary sensors * Add some small test for name * Add translations for sensors * Correct some tests * Adjust summation key * Add translation keys for button * Add translation keys to climate * Add translation keys for cover * Add translation keys to fan * Add translations to light * Add translations for lock * Add translation keys to number * Add translationk keys to select * Add translations for switch entities * Add translation to alarm control panel * Map to some more standard device classes * Use shorter references * Remove explicit name from identify button * Correct tests * Correction after rebase --- .../components/zha/alarm_control_panel.py | 2 +- homeassistant/components/zha/binary_sensor.py | 41 +- homeassistant/components/zha/button.py | 9 +- homeassistant/components/zha/climate.py | 2 +- homeassistant/components/zha/cover.py | 7 +- homeassistant/components/zha/fan.py | 7 +- homeassistant/components/zha/light.py | 8 +- homeassistant/components/zha/lock.py | 2 +- homeassistant/components/zha/number.py | 71 ++-- homeassistant/components/zha/select.py | 40 +- homeassistant/components/zha/sensor.py | 65 ++- homeassistant/components/zha/strings.json | 398 ++++++++++++++++++ homeassistant/components/zha/switch.py | 56 +-- tests/components/zha/test_binary_sensor.py | 25 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_registries.py | 7 + tests/components/zha/test_sensor.py | 30 +- tests/components/zha/zha_devices_list.py | 108 +++-- 18 files changed, 633 insertions(+), 247 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 21cacfa5dd4..bb7cfe67fb3 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -81,7 +81,7 @@ async def async_setup_entry( class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" - _attr_name: str = "Alarm control panel" + _attr_translation_key: str = "alarm_control_panel" _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 9929b43a439..0118625293a 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -43,15 +43,6 @@ IAS_ZONE_CLASS_MAPPING = { IasZone.ZoneType.Vibration_Movement_Sensor: BinarySensorDeviceClass.VIBRATION, } -IAS_ZONE_NAME_MAPPING = { - IasZone.ZoneType.Motion_Sensor: "Motion", - IasZone.ZoneType.Contact_Switch: "Opening", - IasZone.ZoneType.Fire_Sensor: "Smoke", - IasZone.ZoneType.Water_Sensor: "Moisture", - IasZone.ZoneType.Carbon_Monoxide_Sensor: "Gas", - IasZone.ZoneType.Vibration_Movement_Sensor: "Vibration", -} - STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.BINARY_SENSOR) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BINARY_SENSOR) CONFIG_DIAGNOSTIC_MATCH = functools.partial( @@ -119,8 +110,8 @@ class Accelerometer(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "acceleration" - _attr_name: str = "Accelerometer" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING + _attr_translation_key: str = "accelerometer" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_OCCUPANCY) @@ -128,7 +119,6 @@ class Occupancy(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "occupancy" - _attr_name: str = "Occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY @@ -136,13 +126,14 @@ class Occupancy(BinarySensor): class HueOccupancy(Occupancy): """ZHA Hue occupancy.""" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY + @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ON_OFF) class Opening(BinarySensor): """ZHA OnOff BinarySensor.""" SENSOR_ATTR = "on_off" - _attr_name: str = "Opening" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. @@ -161,7 +152,7 @@ class BinaryInput(BinarySensor): """ZHA BinarySensor.""" SENSOR_ATTR = "present_value" - _attr_name: str = "Binary input" + _attr_translation_key: str = "binary_input" @STRICT_MATCH( @@ -179,7 +170,6 @@ class BinaryInput(BinarySensor): class Motion(Opening): """ZHA OnOff BinarySensor with motion device class.""" - _attr_name: str = "Motion" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOTION @@ -190,10 +180,12 @@ class IASZone(BinarySensor): SENSOR_ATTR = "zone_status" @property - def name(self) -> str | None: + def translation_key(self) -> str | None: """Return the name of the sensor.""" zone_type = self._cluster_handler.cluster.get("zone_type") - return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") + if zone_type in IAS_ZONE_CLASS_MAPPING: + return None + return "ias_zone" @property def device_class(self) -> BinarySensorDeviceClass | None: @@ -242,7 +234,6 @@ class SinopeLeakStatus(BinarySensor): """Sinope water leak sensor.""" SENSOR_ATTR = "leak_status" - _attr_name = "Moisture" _attr_device_class = BinarySensorDeviceClass.MOISTURE @@ -258,7 +249,7 @@ class FrostLock(BinarySensor): SENSOR_ATTR = "frost_lock" _unique_id_suffix = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK - _attr_name: str = "Frost lock" + _attr_translation_key: str = "frost_lock" @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") @@ -269,7 +260,7 @@ class ReplaceFilter(BinarySensor): _unique_id_suffix = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "Replace filter" + _attr_translation_key: str = "replace_filter" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) @@ -279,7 +270,6 @@ class AqaraPetFeederErrorDetected(BinarySensor): SENSOR_ATTR = "error_detected" _unique_id_suffix = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM - _attr_name: str = "Error detected" @MULTI_MATCH( @@ -291,8 +281,8 @@ class XiaomiPlugConsumerConnected(BinarySensor): SENSOR_ATTR = "consumer_connected" _unique_id_suffix = "consumer_connected" - _attr_name: str = "Consumer connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG + _attr_translation_key: str = "consumer_connected" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) @@ -302,7 +292,6 @@ class AqaraThermostatWindowOpen(BinarySensor): SENSOR_ATTR = "window_open" _unique_id_suffix = "window_open" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW - _attr_name: str = "Window open" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"}) @@ -312,7 +301,7 @@ class AqaraThermostatValveAlarm(BinarySensor): SENSOR_ATTR = "valve_alarm" _unique_id_suffix = "valve_alarm" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM - _attr_name: str = "Valve alarm" + _attr_translation_key: str = "valve_alarm" @CONFIG_DIAGNOSTIC_MATCH( @@ -324,7 +313,7 @@ class AqaraThermostatCalibrated(BinarySensor): SENSOR_ATTR = "calibrated" _unique_id_suffix = "calibrated" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "Calibrated" + _attr_translation_key: str = "calibrated" @CONFIG_DIAGNOSTIC_MATCH( @@ -336,7 +325,7 @@ class AqaraThermostatExternalSensor(BinarySensor): SENSOR_ATTR = "sensor" _unique_id_suffix = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC - _attr_name: str = "External sensor" + _attr_translation_key: str = "external_sensor" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) @@ -345,5 +334,5 @@ class AqaraLinkageAlarmState(BinarySensor): SENSOR_ATTR = "linkage_alarm_state" _unique_id_suffix = "linkage_alarm_state" - _attr_name: str = "Linkage alarm state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE + _attr_translation_key: str = "linkage_alarm_state" diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 35fee436ec5..e16ae082eda 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -105,7 +105,6 @@ class ZHAIdentifyButton(ZHAButton): _attr_device_class = ButtonDeviceClass.IDENTIFY _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name = "Identify" _command_name = "identify" def get_args(self) -> list[Any]: @@ -150,10 +149,10 @@ class FrostLockResetButton(ZHAAttributeButton): _unique_id_suffix = "reset_frost_lock" _attribute_name = "frost_lock_reset" - _attr_name = "Frost lock reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_frost_lock" @CONFIG_DIAGNOSTIC_MATCH( @@ -164,10 +163,10 @@ class NoPresenceStatusResetButton(ZHAAttributeButton): _unique_id_suffix = "reset_no_presence_status" _attribute_name = "reset_no_presence_status" - _attr_name = "Presence status reset" _attribute_value = 1 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "reset_no_presence_status" @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) @@ -176,8 +175,8 @@ class AqaraPetFeederFeedButton(ZHAAttributeButton): _unique_id_suffix = "feeding" _attribute_name = "feeding" - _attr_name = "Feed" _attribute_value = 1 + _attr_translation_key = "feed" @CONFIG_DIAGNOSTIC_MATCH( @@ -188,6 +187,6 @@ class AqaraSelfTestButton(ZHAAttributeButton): _unique_id_suffix = "self_test" _attribute_name = "self_test" - _attr_name = "Self-test" _attribute_value = 1 _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "self_test" diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 1151d2fe59d..95abaf1c83e 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -140,7 +140,7 @@ class Thermostat(ZhaEntity, ClimateEntity): _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS - _attr_name: str = "Thermostat" + _attr_translation_key: str = "thermostat" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize ZHA Thermostat instance.""" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index f2aed0390f3..d142aa2726b 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -73,7 +73,7 @@ async def async_setup_entry( class ZhaCover(ZhaEntity, CoverEntity): """Representation of a ZHA cover.""" - _attr_name: str = "Cover" + _attr_translation_key: str = "cover" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" @@ -205,7 +205,7 @@ class Shade(ZhaEntity, CoverEntity): """ZHA Shade.""" _attr_device_class = CoverDeviceClass.SHADE - _attr_name: str = "Shade" + _attr_translation_key: str = "shade" def __init__( self, @@ -313,9 +313,8 @@ class Shade(ZhaEntity, CoverEntity): class KeenVent(Shade): """Keen vent cover.""" - _attr_name: str = "Keen vent" - _attr_device_class = CoverDeviceClass.DAMPER + _attr_translation_key: str = "keen_vent" async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 73b128db109..5473b7f0183 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -80,6 +80,7 @@ class BaseFan(FanEntity): """Base representation of a ZHA fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_translation_key: str = "fan" @property def preset_modes(self) -> list[str]: @@ -133,8 +134,6 @@ class BaseFan(FanEntity): class ZhaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - _attr_name: str = "Fan" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @@ -181,6 +180,8 @@ class ZhaFan(BaseFan, ZhaEntity): class FanGroup(BaseFan, ZhaGroupEntity): """Representation of a fan group.""" + _attr_translation_key: str = "fan_group" + def __init__( self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs ) -> None: @@ -262,8 +263,6 @@ IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) class IkeaFan(BaseFan, ZhaEntity): """Representation of a ZHA fan.""" - _attr_name: str = "IKEA fan" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 967d0fc9134..6770ca3b563 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -637,8 +637,8 @@ class BaseLight(LogMixin, light.LightEntity): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" - _attr_name: str = "Light" _attr_supported_color_modes: set[ColorMode] + _attr_translation_key: str = "light" _REFRESH_INTERVAL = (45, 75) def __init__( @@ -1066,7 +1066,6 @@ class Light(BaseLight, ZhaEntity): class HueLight(Light): """Representation of a HUE light which does not report attributes.""" - _attr_name: str = "Light" _REFRESH_INTERVAL = (3, 5) @@ -1078,7 +1077,6 @@ class HueLight(Light): class ForceOnLight(Light): """Representation of a light which does not respect on/off for move_to_level_with_on_off commands.""" - _attr_name: str = "Light" _FORCE_ON = True @@ -1090,8 +1088,6 @@ class ForceOnLight(Light): class MinTransitionLight(Light): """Representation of a light which does not react to any "move to" calls with 0 as a transition.""" - _attr_name: str = "Light" - # Transitions are counted in 1/10th of a second increments, so this is the smallest _DEFAULT_MIN_TRANSITION_TIME = 0.1 @@ -1100,6 +1096,8 @@ class MinTransitionLight(Light): class LightGroup(BaseLight, ZhaGroupEntity): """Representation of a light group.""" + _attr_translation_key: str = "light_group" + def __init__( self, entity_ids: list[str], diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 9bac9a59a38..ccfb5434154 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -97,7 +97,7 @@ async def async_setup_entry( class ZhaDoorLock(ZhaEntity, LockEntity): """Representation of a ZHA lock.""" - _attr_name: str = "Door lock" + _attr_translation_key: str = "door_lock" def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Init this sensor.""" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 01cfa763cd5..ad5f1debcd8 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -278,7 +278,7 @@ async def async_setup_entry( class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" - _attr_name: str = "Number" + _attr_translation_key: str = "number" def __init__( self, @@ -459,7 +459,7 @@ class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" - _attr_name = "Detection interval" + _attr_translation_key: str = "detection_interval" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -471,7 +471,7 @@ class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" - _attr_name = "On/Off transition time" + _attr_translation_key: str = "on_off_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -483,7 +483,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" - _attr_name = "On level" + _attr_translation_key: str = "on_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -495,7 +495,7 @@ class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" - _attr_name = "On transition time" + _attr_translation_key: str = "on_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -507,7 +507,7 @@ class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" - _attr_name = "Off transition time" + _attr_translation_key: str = "off_transition_time" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -519,7 +519,7 @@ class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" - _attr_name = "Default move rate" + _attr_translation_key: str = "default_move_rate" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) @@ -531,7 +531,7 @@ class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" - _attr_name = "Start-up current level" + _attr_translation_key: str = "start_up_current_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) @@ -543,7 +543,7 @@ class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 153 _attr_native_max_value: float = 500 _zcl_attribute: str = "start_up_color_temperature" - _attr_name = "Start-up color temperature" + _attr_translation_key: str = "start_up_color_temperature" def __init__( self, @@ -576,7 +576,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity): _attr_native_max_value: float = 0x257 _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" - _attr_name = "Timer duration" + _attr_translation_key: str = "timer_duration" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") @@ -591,7 +591,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity): _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "filter_life_time" - _attr_name = "Filter life time" + _attr_translation_key: str = "filter_life_time" @CONFIG_DIAGNOSTIC_MATCH( @@ -607,7 +607,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity): _attr_native_min_value: float = -20 _attr_native_max_value: float = 20 _zcl_attribute: str = "transmit_power" - _attr_name = "Transmit power" + _attr_translation_key: str = "transmit_power" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -621,7 +621,7 @@ class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 126 _zcl_attribute: str = "dimming_speed_up_remote" - _attr_name: str = "Remote dimming up speed" + _attr_translation_key: str = "dimming_speed_up_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -635,7 +635,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 9 _zcl_attribute: str = "button_delay" - _attr_name: str = "Button delay" + _attr_translation_key: str = "button_delay" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -649,7 +649,7 @@ class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "dimming_speed_up_local" - _attr_name: str = "Local dimming up speed" + _attr_translation_key: str = "dimming_speed_up_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -663,6 +663,8 @@ class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "ramp_rate_off_to_on_local" + _attr_translation_key: str = "ramp_rate_off_to_on_local" + _attr_name: str = "Local ramp rate off to on" @@ -677,7 +679,7 @@ class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "ramp_rate_off_to_on_remote" - _attr_name: str = "Remote ramp rate off to on" + _attr_translation_key: str = "ramp_rate_off_to_on_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -691,7 +693,7 @@ class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "dimming_speed_down_remote" - _attr_name: str = "Remote dimming down speed" + _attr_translation_key: str = "dimming_speed_down_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -705,7 +707,7 @@ class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "dimming_speed_down_local" - _attr_name: str = "Local dimming down speed" + _attr_translation_key: str = "dimming_speed_down_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -719,7 +721,7 @@ class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "ramp_rate_on_to_off_local" - _attr_name: str = "Local ramp rate on to off" + _attr_translation_key: str = "ramp_rate_on_to_off_local" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -733,7 +735,7 @@ class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 _zcl_attribute: str = "ramp_rate_on_to_off_remote" - _attr_name: str = "Remote ramp rate on to off" + _attr_translation_key: str = "ramp_rate_on_to_off_remote" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -747,7 +749,7 @@ class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): _attr_native_min_value: float = 1 _attr_native_max_value: float = 254 _zcl_attribute: str = "minimum_level" - _attr_name: str = "Minimum load dimming level" + _attr_translation_key: str = "minimum_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -761,7 +763,7 @@ class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): _attr_native_min_value: float = 2 _attr_native_max_value: float = 255 _zcl_attribute: str = "maximum_level" - _attr_name: str = "Maximum load dimming level" + _attr_translation_key: str = "maximum_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -775,7 +777,7 @@ class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 32767 _zcl_attribute: str = "auto_off_timer" - _attr_name: str = "Automatic switch shutoff timer" + _attr_translation_key: str = "auto_off_timer" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -789,7 +791,7 @@ class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 11 _zcl_attribute: str = "load_level_indicator_timeout" - _attr_name: str = "Load level indicator timeout" + _attr_translation_key: str = "load_level_indicator_timeout" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -803,7 +805,7 @@ class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 _zcl_attribute: str = "led_color_when_on" - _attr_name: str = "Default all LED on color" + _attr_translation_key: str = "led_color_when_on" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -817,7 +819,7 @@ class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 _zcl_attribute: str = "led_color_when_off" - _attr_name: str = "Default all LED off color" + _attr_translation_key: str = "led_color_when_off" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -831,7 +833,7 @@ class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 _zcl_attribute: str = "led_intensity_when_on" - _attr_name: str = "Default all LED on intensity" + _attr_translation_key: str = "led_intensity_when_on" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -845,7 +847,7 @@ class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 _zcl_attribute: str = "led_intensity_when_off" - _attr_name: str = "Default all LED off intensity" + _attr_translation_key: str = "led_intensity_when_off" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -859,7 +861,7 @@ class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): _attr_native_min_value: float = 2 _attr_native_max_value: float = 254 _zcl_attribute: str = "double_tap_up_level" - _attr_name: str = "Double tap up level" + _attr_translation_key: str = "double_tap_up_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) @@ -873,7 +875,7 @@ class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0 _attr_native_max_value: float = 254 _zcl_attribute: str = "double_tap_down_level" - _attr_name: str = "Double tap down level" + _attr_translation_key: str = "double_tap_down_level" @CONFIG_DIAGNOSTIC_MATCH( @@ -888,7 +890,8 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): _attr_native_min_value: float = 1 _attr_native_max_value: float = 10 _zcl_attribute: str = "serving_size" - _attr_name: str = "Serving to dispense" + _attr_translation_key: str = "serving_size" + _attr_mode: NumberMode = NumberMode.BOX _attr_icon: str = "mdi:counter" @@ -905,7 +908,8 @@ class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): _attr_native_min_value: float = 1 _attr_native_max_value: float = 100 _zcl_attribute: str = "portion_weight" - _attr_name: str = "Portion weight" + _attr_translation_key: str = "portion_weight" + _attr_mode: NumberMode = NumberMode.BOX _attr_native_unit_of_measurement: str = UnitOfMass.GRAMS _attr_icon: str = "mdi:weight-gram" @@ -924,7 +928,8 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_native_max_value: float = 30 _attr_multiplier: float = 0.01 _zcl_attribute: str = "away_preset_temperature" - _attr_name: str = "Away preset temperature" + _attr_translation_key: str = "away_preset_temperature" + _attr_mode: NumberMode = NumberMode.SLIDER _attr_native_unit_of_measurement: str = UnitOfTemperature.CELSIUS _attr_icon: str = ICONS[0] diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 6f7563e2e23..980f589819c 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -122,7 +122,7 @@ class ZHADefaultToneSelectEntity(ZHANonZCLSelectEntity): _unique_id_suffix = IasWd.Warning.WarningMode.__name__ _enum = IasWd.Warning.WarningMode - _attr_name = "Default siren tone" + _attr_translation_key: str = "default_siren_tone" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) @@ -131,7 +131,7 @@ class ZHADefaultSirenLevelSelectEntity(ZHANonZCLSelectEntity): _unique_id_suffix = IasWd.Warning.SirenLevel.__name__ _enum = IasWd.Warning.SirenLevel - _attr_name = "Default siren level" + _attr_translation_key: str = "default_siren_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) @@ -140,7 +140,7 @@ class ZHADefaultStrobeLevelSelectEntity(ZHANonZCLSelectEntity): _unique_id_suffix = IasWd.StrobeLevel.__name__ _enum = IasWd.StrobeLevel - _attr_name = "Default strobe level" + _attr_translation_key: str = "default_strobe_level" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_IAS_WD) @@ -149,7 +149,7 @@ class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): _unique_id_suffix = Strobe.__name__ _enum = Strobe - _attr_name = "Default strobe" + _attr_translation_key: str = "default_strobe" class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @@ -234,7 +234,7 @@ class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): _unique_id_suffix = OnOff.StartUpOnOff.__name__ _select_attr = "start_up_on_off" _enum = OnOff.StartUpOnOff - _attr_name = "Start-up behavior" + _attr_translation_key: str = "start_up_on_off" class TuyaPowerOnState(types.enum8): @@ -276,7 +276,7 @@ class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): _unique_id_suffix = "power_on_state" _select_attr = "power_on_state" _enum = TuyaPowerOnState - _attr_name = "Power on state" + _attr_translation_key: str = "power_on_state" class TuyaBacklightMode(types.enum8): @@ -297,7 +297,7 @@ class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): _unique_id_suffix = "backlight_mode" _select_attr = "backlight_mode" _enum = TuyaBacklightMode - _attr_name = "Backlight mode" + _attr_translation_key: str = "backlight_mode" class MoesBacklightMode(types.enum8): @@ -336,7 +336,7 @@ class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): _unique_id_suffix = "backlight_mode" _select_attr = "backlight_mode" _enum = MoesBacklightMode - _attr_name = "Backlight mode" + _attr_translation_key: str = "backlight_mode" class AqaraMotionSensitivities(types.enum8): @@ -357,7 +357,7 @@ class AqaraMotionSensitivity(ZCLEnumSelectEntity): _unique_id_suffix = "motion_sensitivity" _select_attr = "motion_sensitivity" _enum = AqaraMotionSensitivities - _attr_name = "Motion sensitivity" + _attr_translation_key: str = "motion_sensitivity" class HueV1MotionSensitivities(types.enum8): @@ -378,8 +378,8 @@ class HueV1MotionSensitivity(ZCLEnumSelectEntity): _unique_id_suffix = "motion_sensitivity" _select_attr = "sensitivity" - _attr_name = "Hue motion sensitivity" _enum = HueV1MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" class HueV2MotionSensitivities(types.enum8): @@ -402,8 +402,8 @@ class HueV2MotionSensitivity(ZCLEnumSelectEntity): _unique_id_suffix = "motion_sensitivity" _select_attr = "sensitivity" - _attr_name = "Hue motion sensitivity" _enum = HueV2MotionSensitivities + _attr_translation_key: str = "motion_sensitivity" class AqaraMonitoringModess(types.enum8): @@ -422,7 +422,7 @@ class AqaraMonitoringMode(ZCLEnumSelectEntity): _unique_id_suffix = "monitoring_mode" _select_attr = "monitoring_mode" _enum = AqaraMonitoringModess - _attr_name = "Monitoring mode" + _attr_translation_key: str = "monitoring_mode" class AqaraApproachDistances(types.enum8): @@ -442,7 +442,7 @@ class AqaraApproachDistance(ZCLEnumSelectEntity): _unique_id_suffix = "approach_distance" _select_attr = "approach_distance" _enum = AqaraApproachDistances - _attr_name = "Approach distance" + _attr_translation_key: str = "approach_distance" class AqaraE1ReverseDirection(types.enum8): @@ -461,7 +461,7 @@ class AqaraCurtainMode(ZCLEnumSelectEntity): _unique_id_suffix = "window_covering_mode" _select_attr = "window_covering_mode" _enum = AqaraE1ReverseDirection - _attr_name = "Curtain mode" + _attr_translation_key: str = "window_covering_mode" class InovelliOutputMode(types.enum1): @@ -480,7 +480,7 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity): _unique_id_suffix = "output_mode" _select_attr = "output_mode" _enum = InovelliOutputMode - _attr_name: str = "Output mode" + _attr_translation_key: str = "output_mode" class InovelliSwitchType(types.enum8): @@ -501,7 +501,7 @@ class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): _unique_id_suffix = "switch_type" _select_attr = "switch_type" _enum = InovelliSwitchType - _attr_name: str = "Switch type" + _attr_translation_key: str = "switch_type" class InovelliLedScalingMode(types.enum1): @@ -520,7 +520,7 @@ class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): _unique_id_suffix = "led_scaling_mode" _select_attr = "led_scaling_mode" _enum = InovelliLedScalingMode - _attr_name: str = "Led scaling mode" + _attr_translation_key: str = "led_scaling_mode" class InovelliNonNeutralOutput(types.enum1): @@ -539,7 +539,7 @@ class InovelliNonNeutralOutputEntity(ZCLEnumSelectEntity): _unique_id_suffix = "increased_non_neutral_output" _select_attr = "increased_non_neutral_output" _enum = InovelliNonNeutralOutput - _attr_name: str = "Non neutral output" + _attr_translation_key: str = "increased_non_neutral_output" class AqaraFeedingMode(types.enum8): @@ -558,7 +558,7 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity): _unique_id_suffix = "feeding_mode" _select_attr = "feeding_mode" _enum = AqaraFeedingMode - _attr_name = "Mode" + _attr_translation_key: str = "feeding_mode" _attr_icon: str = "mdi:wrench-clock" @@ -579,4 +579,4 @@ class AqaraThermostatPreset(ZCLEnumSelectEntity): _unique_id_suffix = "preset" _select_attr = "preset" _enum = AqaraThermostatPresetMode - _attr_name = "Preset" + _attr_translation_key: str = "preset" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 66b422e0d8b..8ddedebfa79 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -195,7 +195,7 @@ class AnalogInput(Sensor): """Sensor that displays analog input values.""" SENSOR_ATTR = "present_value" - _attr_name: str = "Analog input" + _attr_translation_key: str = "analog_input" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) @@ -207,7 +207,6 @@ class Battery(Sensor): _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_name: str = "Battery" _attr_native_unit_of_measurement = PERCENTAGE @classmethod @@ -265,7 +264,6 @@ class ElectricalMeasurement(Sensor): SENSOR_ATTR = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Active power" _attr_native_unit_of_measurement: str = UnitOfPower.WATT _div_mul_prefix = "ac_power" @@ -325,7 +323,6 @@ class ElectricalMeasurementApparentPower(ElectricalMeasurement): SENSOR_ATTR = "apparent_power" _unique_id_suffix = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER - _attr_name: str = "Apparent power" _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE _div_mul_prefix = "ac_power" @@ -338,7 +335,6 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): SENSOR_ATTR = "rms_current" _unique_id_suffix = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT - _attr_name: str = "RMS current" _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE _div_mul_prefix = "ac_current" @@ -351,7 +347,6 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): SENSOR_ATTR = "rms_voltage" _unique_id_suffix = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE - _attr_name: str = "RMS voltage" _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _div_mul_prefix = "ac_voltage" @@ -364,7 +359,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement): SENSOR_ATTR = "ac_frequency" _unique_id_suffix = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY - _attr_name: str = "AC frequency" + _attr_translation_key: str = "ac_frequency" _attr_native_unit_of_measurement = UnitOfFrequency.HERTZ _div_mul_prefix = "ac_frequency" @@ -377,7 +372,6 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement): SENSOR_ATTR = "power_factor" _unique_id_suffix = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR - _attr_name: str = "Power factor" _attr_native_unit_of_measurement = PERCENTAGE @@ -396,7 +390,6 @@ class Humidity(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Humidity" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -409,7 +402,7 @@ class SoilMoisture(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Soil moisture" + _attr_translation_key: str = "soil_moisture" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -422,7 +415,7 @@ class LeafWetness(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Leaf wetness" + _attr_translation_key: str = "leaf_wetness" _divisor = 100 _attr_native_unit_of_measurement = PERCENTAGE @@ -435,7 +428,6 @@ class Illuminance(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Illuminance" _attr_native_unit_of_measurement = LIGHT_LUX def formatter(self, value: int) -> int: @@ -454,7 +446,7 @@ class SmartEnergyMetering(Sensor): SENSOR_ATTR: int | str = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Instantaneous demand" + _attr_translation_key: str = "instantaneous_demand" unit_of_measure_map = { 0x00: UnitOfPower.WATT, @@ -509,7 +501,7 @@ class SmartEnergySummation(SmartEnergyMetering): _unique_id_suffix = "summation_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING - _attr_name: str = "Summation delivered" + _attr_translation_key: str = "summation_delivered" unit_of_measure_map = { 0x00: UnitOfEnergy.KILO_WATT_HOUR, @@ -567,7 +559,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier1_summ_delivered" _unique_id_suffix = "tier1_summation_delivered" - _attr_name: str = "Tier 1 summation delivered" + _attr_translation_key: str = "tier1_summation_delivered" @MULTI_MATCH( @@ -580,7 +572,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier2_summ_delivered" _unique_id_suffix = "tier2_summation_delivered" - _attr_name: str = "Tier 2 summation delivered" + _attr_translation_key: str = "tier2_summation_delivered" @MULTI_MATCH( @@ -593,7 +585,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier3_summ_delivered" _unique_id_suffix = "tier3_summation_delivered" - _attr_name: str = "Tier 3 summation delivered" + _attr_translation_key: str = "tier3_summation_delivered" @MULTI_MATCH( @@ -606,7 +598,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier4_summ_delivered" _unique_id_suffix = "tier4_summation_delivered" - _attr_name: str = "Tier 4 summation delivered" + _attr_translation_key: str = "tier4_summation_delivered" @MULTI_MATCH( @@ -619,7 +611,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier5_summ_delivered" _unique_id_suffix = "tier5_summation_delivered" - _attr_name: str = "Tier 5 summation delivered" + _attr_translation_key: str = "tier5_summation_delivered" @MULTI_MATCH( @@ -632,7 +624,7 @@ class Tier6SmartEnergySummation(PolledSmartEnergySummation): SENSOR_ATTR: int | str = "current_tier6_summ_delivered" _unique_id_suffix = "tier6_summation_delivered" - _attr_name: str = "Tier 6 summation delivered" + _attr_translation_key: str = "tier6_summation_delivered" @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) @@ -643,7 +635,6 @@ class Pressure(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Pressure" _decimals = 0 _attr_native_unit_of_measurement = UnitOfPressure.HPA @@ -656,7 +647,6 @@ class Temperature(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Temperature" _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @@ -669,7 +659,7 @@ class DeviceTemperature(Sensor): SENSOR_ATTR = "current_temperature" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Device temperature" + _attr_translation_key: str = "device_temperature" _divisor = 100 _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -683,7 +673,6 @@ class CarbonDioxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Carbon dioxide concentration" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -697,7 +686,6 @@ class CarbonMonoxideConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Carbon monoxide concentration" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -712,7 +700,6 @@ class VOCLevel(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -732,7 +719,6 @@ class PPBVOCLevel(Sensor): SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "VOC level" _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_BILLION @@ -746,7 +732,6 @@ class PM25(Sensor): SENSOR_ATTR = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Particulate matter" _decimals = 0 _multiplier = 1 _attr_native_unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER @@ -759,7 +744,7 @@ class FormaldehydeConcentration(Sensor): SENSOR_ATTR = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT - _attr_name: str = "Formaldehyde concentration" + _attr_translation_key: str = "formaldehyde" _decimals = 0 _multiplier = 1e6 _attr_native_unit_of_measurement = CONCENTRATION_PARTS_PER_MILLION @@ -774,7 +759,7 @@ class ThermostatHVACAction(Sensor): """Thermostat HVAC action sensor.""" _unique_id_suffix = "hvac_action" - _attr_name: str = "HVAC action" + _attr_translation_key: str = "hvac_action" @classmethod def create_entity( @@ -901,7 +886,7 @@ class RSSISensor(Sensor): _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False _attr_should_poll = True # BaseZhaEntity defaults to False - _attr_name: str = "RSSI" + _attr_translation_key: str = "rssi" @classmethod def create_entity( @@ -933,9 +918,9 @@ class LQISensor(RSSISensor): SENSOR_ATTR = "lqi" _unique_id_suffix = "lqi" - _attr_name: str = "LQI" _attr_device_class = None _attr_native_unit_of_measurement = None + _attr_translation_key = "lqi" @MULTI_MATCH( @@ -952,7 +937,7 @@ class TimeLeft(Sensor): _unique_id_suffix = "time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Time left" + _attr_translation_key: str = "timer_time_left" _attr_native_unit_of_measurement = UnitOfTime.MINUTES @@ -965,7 +950,7 @@ class IkeaDeviceRunTime(Sensor): _unique_id_suffix = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Device run time" + _attr_translation_key: str = "device_run_time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @@ -979,7 +964,7 @@ class IkeaFilterRunTime(Sensor): _unique_id_suffix = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" - _attr_name: str = "Filter run time" + _attr_translation_key: str = "filter_run_time" _attr_native_unit_of_measurement = UnitOfTime.MINUTES _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @@ -998,7 +983,7 @@ class AqaraPetFeederLastFeedingSource(Sensor): SENSOR_ATTR = "last_feeding_source" _unique_id_suffix = "last_feeding_source" - _attr_name: str = "Last feeding source" + _attr_translation_key: str = "last_feeding_source" _attr_icon = "mdi:devices" def formatter(self, value: int) -> int | float | None: @@ -1013,7 +998,7 @@ class AqaraPetFeederLastFeedingSize(Sensor): SENSOR_ATTR = "last_feeding_size" _unique_id_suffix = "last_feeding_size" - _attr_name: str = "Last feeding size" + _attr_translation_key: str = "last_feeding_size" _attr_icon: str = "mdi:counter" @@ -1024,7 +1009,7 @@ class AqaraPetFeederPortionsDispensed(Sensor): SENSOR_ATTR = "portions_dispensed" _unique_id_suffix = "portions_dispensed" - _attr_name: str = "Portions dispensed today" + _attr_translation_key: str = "portions_dispensed_today" _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:counter" @@ -1036,7 +1021,7 @@ class AqaraPetFeederWeightDispensed(Sensor): SENSOR_ATTR = "weight_dispensed" _unique_id_suffix = "weight_dispensed" - _attr_name: str = "Weight dispensed today" + _attr_translation_key: str = "weight_dispensed_today" _attr_native_unit_of_measurement = UnitOfMass.GRAMS _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING _attr_icon: str = "mdi:weight-gram" @@ -1049,7 +1034,7 @@ class AqaraSmokeDensityDbm(Sensor): SENSOR_ATTR = "smoke_density_dbm" _unique_id_suffix = "smoke_density_dbm" - _attr_name: str = "Smoke density" + _attr_translation_key: str = "smoke_density" _attr_native_unit_of_measurement = "dB/m" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_icon: str = "mdi:google-circles-communities" diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 21bf95f7ce6..56381d993b8 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -529,5 +529,403 @@ } } } + }, + "entity": { + "alarm_control_panel": { + "alarm_control_panel": { + "name": "[%key:component::alarm_control_panel::title%]" + } + }, + "binary_sensor": { + "accelerometer": { + "name": "Accelerometer" + }, + "binary_input": { + "name": "Binary input" + }, + "frost_lock": { + "name": "Frost lock" + }, + "replace_filter": { + "name": "Replace filter" + }, + "consumer_connected": { + "name": "Consumer connected" + }, + "valve_alarm": { + "name": "Valve alarm" + }, + "calibrated": { + "name": "Calibrated" + }, + "external_sensor": { + "name": "External sensor" + }, + "linkage_alarm_state": { + "name": "Linkage alarm state" + }, + "ias_zone": { + "name": "IAS zone" + } + }, + "button": { + "reset_frost_lock": { + "name": "Frost lock reset" + }, + "reset_no_presence_status": { + "name": "Presence status reset" + }, + "feed": { + "name": "Feed" + }, + "self_test": { + "name": "Self-test" + } + }, + "climate": { + "thermostat": { + "name": "[%key:component::climate::entity_component::_::name%]" + } + }, + "cover": { + "cover": { + "name": "[%key:component::cover::title%]" + }, + "shade": { + "name": "[%key:component::cover::entity_component::shade::name%]" + }, + "keen_vent": { + "name": "Keen vent" + } + }, + "fan": { + "fan": { + "name": "[%key:component::fan::title%]" + }, + "fan_group": { + "name": "Fan group" + } + }, + "light": { + "light": { + "name": "[%key:component::light::title%]" + }, + "light_group": { + "name": "Light group" + } + }, + "lock": { + "door_lock": { + "name": "Door lock" + } + }, + "number": { + "number": { + "name": "[%key:component::number::entity_component::_::name%]" + }, + "detection_interval": { + "name": "Detection interval" + }, + "on_level": { + "name": "On level" + }, + "on_off_transition_time": { + "name": "On/Off transition time" + }, + "on_transition_time": { + "name": "On transition time" + }, + "off_transition_time": { + "name": "Off transition time" + }, + "default_move_rate": { + "name": "Default move rate" + }, + "start_up_current_level": { + "name": "Start-up current level" + }, + "start_up_color_temperature": { + "name": "Start-up color temperature" + }, + "timer_duration": { + "name": "Timer duration" + }, + "filter_life_time": { + "name": "Filter life time" + }, + "transmit_power": { + "name": "Transmit power" + }, + "dimming_speed_up_remote": { + "name": "Remote dimming up speed" + }, + "button_delay": { + "name": "Button delay" + }, + "dimming_speed_up_local": { + "name": "Local dimming up speed" + }, + "ramp_rate_off_to_on_local": { + "name": "Local ramp rate off to on" + }, + "ramp_rate_off_to_on_remote": { + "name": "Remote ramp rate off to on" + }, + "dimming_speed_down_remote": { + "name": "Remote dimming down speed" + }, + "dimming_speed_down_local": { + "name": "Local dimming down speed" + }, + "ramp_rate_on_to_off_local": { + "name": "Local ramp rate on to off" + }, + "ramp_rate_on_to_off_remote": { + "name": "Remote ramp rate on to off" + }, + "minimum_level": { + "name": "Minimum load dimming level" + }, + "maximum_level": { + "name": "Maximum load dimming level" + }, + "auto_off_timer": { + "name": "Automatic switch shutoff timer" + }, + "load_level_indicator_timeout": { + "name": "Load level indicator timeout" + }, + "led_color_when_on": { + "name": "Default all LED on color" + }, + "led_color_when_off": { + "name": "Default all LED off color" + }, + "led_intensity_when_on": { + "name": "Default all LED on intensity" + }, + "led_intensity_when_off": { + "name": "Default all LED off intensity" + }, + "double_tap_up_level": { + "name": "Double tap up level" + }, + "double_tap_down_level": { + "name": "Double tap down level" + }, + "serving_size": { + "name": "Serving to dispense" + }, + "portion_weight": { + "name": "Portion weight" + }, + "away_preset_temperature": { + "name": "Away preset temperature" + } + }, + "select": { + "default_siren_tone": { + "name": "Default siren tone" + }, + "default_siren_level": { + "name": "Default siren level" + }, + "default_strobe_level": { + "name": "Default strobe level" + }, + "default_strobe": { + "name": "Default strobe" + }, + "start_up_on_off": { + "name": "Start-up behavior" + }, + "power_on_state": { + "name": "Power on state" + }, + "backlight_mode": { + "name": "Backlight mode" + }, + "motion_sensitivity": { + "name": "Motion sensitivity" + }, + "monitoring_mode": { + "name": "Monitoring mode" + }, + "approach_distance": { + "name": "Approach distance" + }, + "window_covering_mode": { + "name": "Curtain mode" + }, + "output_mode": { + "name": "Output mode" + }, + "switch_type": { + "name": "Switch type" + }, + "led_scaling_mode": { + "name": "Led scaling mode" + }, + "increased_non_neutral_output": { + "name": "Non neutral output" + }, + "feeding_mode": { + "name": "Mode" + }, + "preset": { + "name": "Preset" + } + }, + "sensor": { + "analog_input": { + "name": "Analog input" + }, + "ac_frequency": { + "name": "AC frequency" + }, + "soil_moisture": { + "name": "Soil moisture" + }, + "leaf_wetness": { + "name": "Leaf wetness" + }, + "instantaneous_demand": { + "name": "Instantaneous demand" + }, + "summation_delivered": { + "name": "Summation delivered" + }, + "tier1_summation_delivered": { + "name": "Tier 1 summation delivered" + }, + "tier2_summation_delivered": { + "name": "Tier 2 summation delivered" + }, + "tier3_summation_delivered": { + "name": "Tier 3 summation delivered" + }, + "tier4_summation_delivered": { + "name": "Tier 4 summation delivered" + }, + "tier5_summation_delivered": { + "name": "Tier 5 summation delivered" + }, + "tier6_summation_delivered": { + "name": "Tier 6 summation delivered" + }, + "device_temperature": { + "name": "Device temperature" + }, + "formaldehyde": { + "name": "Formaldehyde concentration" + }, + "hvac_action": { + "name": "HVAC action" + }, + "rssi": { + "name": "RSSI" + }, + "lqi": { + "name": "LQI" + }, + "timer_time_left": { + "name": "Time left" + }, + "device_run_time": { + "name": "Device run time" + }, + "filter_run_time": { + "name": "Filter run time" + }, + "last_feeding_source": { + "name": "Last feeding source" + }, + "last_feeding_size": { + "name": "Last feeding size" + }, + "portions_dispensed_today": { + "name": "Portions dispensed today" + }, + "weight_dispensed_today": { + "name": "Weight dispensed today" + }, + "smoke_density": { + "name": "Smoke density" + } + }, + "switch": { + "switch": { + "name": "[%key:component::switch::title%]" + }, + "window_detection_function": { + "name": "Invert window detection" + }, + "trigger_indicator": { + "name": "LED trigger indicator" + }, + "power_outage_memory": { + "name": "Power outage memory" + }, + "child_lock": { + "name": "Child lock" + }, + "disable_led": { + "name": "Disable LED" + }, + "invert_switch": { + "name": "Invert switch" + }, + "smart_bulb_mode": { + "name": "Smart bulb mode" + }, + "double_tap_up_enabled": { + "name": "Double tap up enabled" + }, + "double_tap_down_enabled": { + "name": "Double tap down enabled" + }, + "aux_switch_scenes": { + "name": "Aux switch scenes" + }, + "binding_off_to_on_sync_level": { + "name": "Binding off to on sync level" + }, + "local_protection": { + "name": "Local protection" + }, + "one_led_mode": { + "name": "Only 1 LED mode" + }, + "firmware_progress_led": { + "name": "Firmware progress LED" + }, + "relay_click_in_on_off_mode": { + "name": "Disable relay click in on off mode" + }, + "disable_clear_notifications_double_tap": { + "name": "Disable config 2x tap to clear notifications" + }, + "led_indicator": { + "name": "LED indicator" + }, + "window_detection": { + "name": "Window detection" + }, + "valve_detection": { + "name": "Valve detection" + }, + "heartbeat_indicator": { + "name": "Heartbeat indicator" + }, + "linkage_alarm": { + "name": "Linkage alarm" + }, + "buzzer_manual_mute": { + "name": "Buzzer manual mute" + }, + "buzzer_manual_alarm": { + "name": "Buzzer manual alarm" + } + } } } diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 6224eb02598..495c9470e53 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -63,7 +63,7 @@ async def async_setup_entry( class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" - _attr_name: str = "Switch" + _attr_translation_key = "switch" def __init__( self, @@ -276,7 +276,7 @@ class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEnti _unique_id_suffix = "on_off_window_opened_detection" _zcl_attribute: str = "window_detection_function" _zcl_inverter_attribute: str = "window_detection_function_inverter" - _attr_name: str = "Invert window detection" + _attr_translation_key = "window_detection_function" @CONFIG_DIAGNOSTIC_MATCH( @@ -287,7 +287,7 @@ class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "trigger_indicator" _zcl_attribute: str = "trigger_indicator" - _attr_name = "LED trigger indicator" + _attr_translation_key = "trigger_indicator" @CONFIG_DIAGNOSTIC_MATCH( @@ -299,7 +299,7 @@ class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "power_outage_memory" _zcl_attribute: str = "power_outage_memory" - _attr_name = "Power outage memory" + _attr_translation_key = "power_outage_memory" @CONFIG_DIAGNOSTIC_MATCH( @@ -312,7 +312,7 @@ class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "trigger_indicator" _zcl_attribute: str = "trigger_indicator" - _attr_name = "LED trigger indicator" + _attr_translation_key = "trigger_indicator" @CONFIG_DIAGNOSTIC_MATCH( @@ -324,7 +324,7 @@ class ChildLock(ZHASwitchConfigurationEntity): _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _attr_translation_key = "child_lock" @CONFIG_DIAGNOSTIC_MATCH( @@ -336,7 +336,7 @@ class DisableLed(ZHASwitchConfigurationEntity): _unique_id_suffix = "disable_led" _zcl_attribute: str = "disable_led" - _attr_name = "Disable LED" + _attr_translation_key = "disable_led" @CONFIG_DIAGNOSTIC_MATCH( @@ -347,7 +347,7 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "invert_switch" _zcl_attribute: str = "invert_switch" - _attr_name: str = "Invert switch" + _attr_translation_key = "invert_switch" @CONFIG_DIAGNOSTIC_MATCH( @@ -358,7 +358,7 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): _unique_id_suffix = "smart_bulb_mode" _zcl_attribute: str = "smart_bulb_mode" - _attr_name: str = "Smart bulb mode" + _attr_translation_key = "smart_bulb_mode" @CONFIG_DIAGNOSTIC_MATCH( @@ -369,7 +369,7 @@ class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): _unique_id_suffix = "double_tap_up_enabled" _zcl_attribute: str = "double_tap_up_enabled" - _attr_name: str = "Double tap up enabled" + _attr_translation_key = "double_tap_up_enabled" @CONFIG_DIAGNOSTIC_MATCH( @@ -380,7 +380,7 @@ class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): _unique_id_suffix = "double_tap_down_enabled" _zcl_attribute: str = "double_tap_down_enabled" - _attr_name: str = "Double tap down enabled" + _attr_translation_key = "double_tap_down_enabled" @CONFIG_DIAGNOSTIC_MATCH( @@ -391,7 +391,7 @@ class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): _unique_id_suffix = "aux_switch_scenes" _zcl_attribute: str = "aux_switch_scenes" - _attr_name: str = "Aux switch scenes" + _attr_translation_key = "aux_switch_scenes" @CONFIG_DIAGNOSTIC_MATCH( @@ -402,7 +402,7 @@ class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): _unique_id_suffix = "binding_off_to_on_sync_level" _zcl_attribute: str = "binding_off_to_on_sync_level" - _attr_name: str = "Binding off to on sync level" + _attr_translation_key = "binding_off_to_on_sync_level" @CONFIG_DIAGNOSTIC_MATCH( @@ -413,7 +413,7 @@ class InovelliLocalProtection(ZHASwitchConfigurationEntity): _unique_id_suffix = "local_protection" _zcl_attribute: str = "local_protection" - _attr_name: str = "Local protection" + _attr_translation_key = "local_protection" @CONFIG_DIAGNOSTIC_MATCH( @@ -424,7 +424,7 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): _unique_id_suffix = "on_off_led_mode" _zcl_attribute: str = "on_off_led_mode" - _attr_name: str = "Only 1 LED mode" + _attr_translation_key = "one_led_mode" @CONFIG_DIAGNOSTIC_MATCH( @@ -435,7 +435,7 @@ class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): _unique_id_suffix = "firmware_progress_led" _zcl_attribute: str = "firmware_progress_led" - _attr_name: str = "Firmware progress LED" + _attr_translation_key = "firmware_progress_led" @CONFIG_DIAGNOSTIC_MATCH( @@ -446,7 +446,7 @@ class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): _unique_id_suffix = "relay_click_in_on_off_mode" _zcl_attribute: str = "relay_click_in_on_off_mode" - _attr_name: str = "Disable relay click in on off mode" + _attr_translation_key = "relay_click_in_on_off_mode" @CONFIG_DIAGNOSTIC_MATCH( @@ -457,7 +457,7 @@ class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntit _unique_id_suffix = "disable_clear_notifications_double_tap" _zcl_attribute: str = "disable_clear_notifications_double_tap" - _attr_name: str = "Disable config 2x tap to clear notifications" + _attr_translation_key = "disable_clear_notifications_double_tap" @CONFIG_DIAGNOSTIC_MATCH( @@ -468,7 +468,7 @@ class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): _unique_id_suffix = "disable_led_indicator" _zcl_attribute: str = "disable_led_indicator" - _attr_name = "LED indicator" + _attr_translation_key = "led_indicator" _force_inverted = True _attr_icon: str = "mdi:led-on" @@ -481,7 +481,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -494,7 +494,7 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -506,7 +506,7 @@ class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): _unique_id_suffix = "window_detection" _zcl_attribute: str = "window_detection" - _attr_name = "Window detection" + _attr_translation_key = "window_detection" @CONFIG_DIAGNOSTIC_MATCH( @@ -517,7 +517,7 @@ class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): _unique_id_suffix = "valve_detection" _zcl_attribute: str = "valve_detection" - _attr_name = "Valve detection" + _attr_translation_key = "valve_detection" @CONFIG_DIAGNOSTIC_MATCH( @@ -528,7 +528,7 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): _unique_id_suffix = "child_lock" _zcl_attribute: str = "child_lock" - _attr_name = "Child lock" + _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -540,7 +540,7 @@ class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): _unique_id_suffix = "heartbeat_indicator" _zcl_attribute: str = "heartbeat_indicator" - _attr_name = "Heartbeat indicator" + _attr_translation_key = "heartbeat_indicator" _attr_icon: str = "mdi:heart-flash" @@ -552,7 +552,7 @@ class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): _unique_id_suffix = "linkage_alarm" _zcl_attribute: str = "linkage_alarm" - _attr_name = "Linkage alarm" + _attr_translation_key = "linkage_alarm" _attr_icon: str = "mdi:shield-link-variant" @@ -564,7 +564,7 @@ class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): _unique_id_suffix = "buzzer_manual_mute" _zcl_attribute: str = "buzzer_manual_mute" - _attr_name = "Buzzer manual mute" + _attr_translation_key = "buzzer_manual_mute" _attr_icon: str = "mdi:volume-off" @@ -576,5 +576,5 @@ class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): _unique_id_suffix = "buzzer_manual_alarm" _zcl_attribute: str = "buzzer_manual_alarm" - _attr_name = "Buzzer manual alarm" + _attr_translation_key = "buzzer_manual_alarm" _attr_icon: str = "mdi:bullhorn" diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 1b4f5b56924..b41499dada7 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -98,10 +98,22 @@ async def async_test_iaszone_on_off(hass, cluster, entity_id): @pytest.mark.parametrize( - ("device", "on_off_test", "cluster_name", "reporting"), + ("device", "on_off_test", "cluster_name", "reporting", "name"), [ - (DEVICE_IAS, async_test_iaszone_on_off, "ias_zone", (0,)), - (DEVICE_OCCUPANCY, async_test_binary_sensor_on_off, "occupancy", (1,)), + ( + DEVICE_IAS, + async_test_iaszone_on_off, + "ias_zone", + (0,), + "FakeManufacturer FakeModel IAS zone", + ), + ( + DEVICE_OCCUPANCY, + async_test_binary_sensor_on_off, + "occupancy", + (1,), + "FakeManufacturer FakeModel Occupancy", + ), ], ) async def test_binary_sensor( @@ -112,6 +124,7 @@ async def test_binary_sensor( on_off_test, cluster_name, reporting, + name, ) -> None: """Test ZHA binary_sensor platform.""" zigpy_device = zigpy_device_mock(device) @@ -119,6 +132,7 @@ async def test_binary_sensor( entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) assert entity_id is not None + assert hass.states.get(entity_id).name == name assert hass.states.get(entity_id).state == STATE_OFF await async_enable_traffic(hass, [zha_device], enabled=False) # test that the sensors exist and are in the unavailable state @@ -186,11 +200,12 @@ async def test_binary_sensor_migration_not_migrated( ) -> None: """Test temporary ZHA IasZone binary_sensor migration to zigpy cache.""" - entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + entity_id = "binary_sensor.fakemanufacturer_fakemodel_ias_zone" core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) + zha_device = await zha_device_restored(zigpy_device) entity_id = find_entity_id(Platform.BINARY_SENSOR, zha_device, hass) @@ -209,7 +224,7 @@ async def test_binary_sensor_migration_already_migrated( ) -> None: """Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times.""" - entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" + entity_id = "binary_sensor.fakemanufacturer_fakemodel_ias_zone" core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) await async_mock_load_restore_state_from_storage(hass) diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 768f974d928..c55f614d80f 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -144,7 +144,7 @@ async def test_devices( _, platform, entity_cls, unique_id, cluster_handlers = call[0] # the factory can return None. We filter these out to get an accurate created entity count response = entity_cls.create_entity(unique_id, zha_dev, cluster_handlers) - if response and not contains_ignored_suffix(response.name): + if response and not contains_ignored_suffix(response.unique_id): created_entity_count += 1 unique_id_head = UNIQUE_ID_HD.match(unique_id).group( 0 diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 6f36ee624e9..2eb61402a95 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -596,6 +596,13 @@ def test_entity_names() -> None: if hasattr(entity, "_attr_name"): # The entity has a name assert isinstance(entity._attr_name, str) and entity._attr_name + elif hasattr(entity, "_attr_translation_key"): + assert ( + isinstance(entity._attr_translation_key, str) + and entity._attr_translation_key + ) + elif hasattr(entity, "_attr_device_class"): + assert entity._attr_device_class else: # The only exception (for now) is IASZone assert entity is IASZone diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index b9d9511a6d1..7c11077c55d 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -347,7 +347,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "active_power", + "power", async_test_electrical_measurement, 7, {"ac_power_divisor": 1000, "ac_power_multiplier": 1}, @@ -363,7 +363,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "rms_current", + "current", async_test_em_rms_current, 7, {"ac_current_divisor": 1000, "ac_current_multiplier": 1}, @@ -371,7 +371,7 @@ async def async_test_device_temperature(hass, cluster, entity_id): ), ( homeautomation.ElectricalMeasurement.cluster_id, - "rms_voltage", + "voltage", async_test_em_rms_voltage, 7, {"ac_voltage_divisor": 10, "ac_voltage_multiplier": 1}, @@ -613,9 +613,7 @@ async def test_electrical_measurement_init( ) cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] zha_device = await zha_device_joined(zigpy_device) - entity_id = find_entity_id( - Platform.SENSOR, zha_device, hass, qualifier="active_power" - ) + entity_id = "sensor.fakemanufacturer_fakemodel_power" # allow traffic to flow through the gateway and devices await async_enable_traffic(hass, [zha_device]) @@ -660,23 +658,23 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_voltage", "rms_current"}, { - "active_power", + "power", "ac_frequency", "power_factor", }, { "apparent_power", - "rms_voltage", - "rms_current", + "voltage", + "current", }, ), ( homeautomation.ElectricalMeasurement.cluster_id, {"apparent_power", "rms_current", "ac_frequency", "power_factor"}, - {"rms_voltage", "active_power"}, + {"voltage", "power"}, { "apparent_power", - "rms_current", + "current", "ac_frequency", "power_factor", }, @@ -685,10 +683,10 @@ async def test_electrical_measurement_init( homeautomation.ElectricalMeasurement.cluster_id, set(), { - "rms_voltage", - "active_power", + "voltage", + "power", "apparent_power", - "rms_current", + "current", "ac_frequency", "power_factor", }, @@ -909,7 +907,7 @@ async def test_elec_measurement_sensor_type( ) -> None: """Test ZHA electrical measurement sensor type.""" - entity_id = ENTITY_ID_PREFIX.format("active_power") + entity_id = ENTITY_ID_PREFIX.format("power") zigpy_dev = elec_measurement_zigpy_dev zigpy_dev.endpoints[1].electrical_measurement.PLUGGED_ATTR_READS[ "measurement_type" @@ -958,7 +956,7 @@ async def test_elec_measurement_skip_unsupported_attribute( ) -> None: """Test ZHA electrical measurement skipping update of unsupported attributes.""" - entity_id = ENTITY_ID_PREFIX.format("active_power") + entity_id = ENTITY_ID_PREFIX.format("power") zha_dev = elec_measurement_zha_dev cluster = zha_dev.device.endpoints[1].electrical_measurement diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index bba5ee124ba..3be6322d1eb 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -100,7 +100,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-5-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.bosch_isw_zpr1_wp13_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-5-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -196,7 +196,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -206,12 +206,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.centralite_3210_l_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -319,7 +319,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3315_s_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -374,7 +374,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3320_l_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -429,7 +429,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_3326_l_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -484,7 +484,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.centralite_motion_sensor_a_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -603,7 +603,7 @@ DEVICES = [ DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: ( - "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_iaszone" + "binary_sensor.climaxtechnology_sd8sc_00_00_03_12tc_ias_zone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -678,7 +678,7 @@ DEVICES = [ DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", DEV_SIG_ENT_MAP_ID: ( - "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_iaszone" + "binary_sensor.climaxtechnology_ws15_00_00_03_03tc_ias_zone" ), }, ("button", "00:11:22:33:44:55:66:77-1-3"): { @@ -764,7 +764,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_smokesensor_em_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -832,7 +832,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_co_v16_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -895,7 +895,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.heiman_warningdevice_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -933,7 +933,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-6-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.hivehome_com_mot003_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-6-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -2081,12 +2081,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -2156,7 +2156,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -2166,12 +2166,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_relay_c2acn01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3205,7 +3205,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.lumi_lumi_sensor_wleak_aq1_ias_zone", }, ("sensor", "00:11:22:33:44:55:66:77-1-2"): { DEV_SIG_CLUSTER_HANDLERS: ["device_temperature"], @@ -3378,7 +3378,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3010_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3421,7 +3421,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.nyce_3014_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3629,9 +3629,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-3-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: ( - "sensor.osram_lightify_rt_tunable_white_active_power" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_power"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3643,16 +3641,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: ( - "sensor.osram_lightify_rt_tunable_white_rms_current" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_current"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: ( - "sensor.osram_lightify_rt_tunable_white_rms_voltage" - ), + DEV_SIG_ENT_MAP_ID: ("sensor.osram_lightify_rt_tunable_white_voltage"), }, ("sensor", "00:11:22:33:44:55:66:77-3-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -3881,7 +3875,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_button_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3929,7 +3923,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_multi_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -3982,7 +3976,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.samjin_water_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4035,7 +4029,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4045,12 +4039,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.securifi_ltd_unk_model_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4098,7 +4092,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_dws04n_sf_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4163,7 +4157,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4173,12 +4167,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sercomm_corp_sz_esw01_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4231,7 +4225,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.sercomm_corp_sz_pir04_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4289,7 +4283,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4301,12 +4295,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_rm3250zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4374,7 +4368,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4386,12 +4380,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1123zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4464,7 +4458,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "PolledElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4476,12 +4470,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.sinope_technologies_th1124zb_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4544,7 +4538,7 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurement", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_active_power", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_power", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-apparent_power"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4554,12 +4548,12 @@ DEVICES = [ ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_current"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSCurrent", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_current", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_current", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-rms_voltage"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", - DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_rms_voltage", + DEV_SIG_ENT_MAP_ID: "sensor.smartthings_outletv4_voltage", }, ("sensor", "00:11:22:33:44:55:66:77-1-2820-ac_frequency"): { DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], @@ -4731,7 +4725,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.visonic_mct_340_e_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -4896,7 +4890,7 @@ DEVICES = [ ("binary_sensor", "00:11:22:33:44:55:66:77-1-1280"): { DEV_SIG_CLUSTER_HANDLERS: ["ias_zone"], DEV_SIG_ENT_MAP_CLASS: "IASZone", - DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_iaszone", + DEV_SIG_ENT_MAP_ID: "binary_sensor.netvox_z308e3ed_ias_zone", }, ("button", "00:11:22:33:44:55:66:77-1-3"): { DEV_SIG_CLUSTER_HANDLERS: ["identify"], @@ -5526,7 +5520,7 @@ DEVICES = [ ("select", "00:11:22:33:44:55:66:77-2-1030-motion_sensitivity"): { DEV_SIG_CLUSTER_HANDLERS: ["philips_occupancy"], DEV_SIG_ENT_MAP_CLASS: "HueV1MotionSensitivity", - DEV_SIG_ENT_MAP_ID: "select.philips_sml001_hue_motion_sensitivity", + DEV_SIG_ENT_MAP_ID: "select.philips_sml001_motion_sensitivity", }, }, }, From 9dd2f37b1195cf5303556740e8ac2b3d370cb256 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 17 Oct 2023 22:04:03 +0200 Subject: [PATCH 510/968] Remove unused variables in ZHA lighting cluster handler (#102138) * Remove unused `UNSUPPORTED_ATTRIBUTE` * Remove unused `CAPABILITIES_COLOR_TEMP` * Use zigpy `ColorCapabilities` and remove `CAPABILITIES_COLOR_XY` --- .../components/zha/core/cluster_handlers/lighting.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 5f54ce381cc..830edfcf1c9 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -25,9 +25,6 @@ class ColorClientClusterHandler(ClientClusterHandler): class ColorClusterHandler(ClusterHandler): """Color cluster handler.""" - CAPABILITIES_COLOR_XY = 0x08 - CAPABILITIES_COLOR_TEMP = 0x10 - UNSUPPORTED_ATTRIBUTE = 0x86 REPORT_CONFIG = ( AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), @@ -53,7 +50,7 @@ class ColorClusterHandler(ClusterHandler): """Return ZCL color capabilities of the light.""" color_capabilities = self.cluster.get("color_capabilities") if color_capabilities is None: - return lighting.Color.ColorCapabilities(self.CAPABILITIES_COLOR_XY) + return lighting.Color.ColorCapabilities.XY_attributes return lighting.Color.ColorCapabilities(color_capabilities) @property From 60c1a8d56f7dfff72161ec79c8d60df497b86158 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 17 Oct 2023 22:23:11 +0200 Subject: [PATCH 511/968] Remove invalid attribute reporting for `enhanced_current_hue` in ZHA (#102137) --- homeassistant/components/zha/core/cluster_handlers/lighting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/lighting.py b/homeassistant/components/zha/core/cluster_handlers/lighting.py index 830edfcf1c9..5f1e52fa241 100644 --- a/homeassistant/components/zha/core/cluster_handlers/lighting.py +++ b/homeassistant/components/zha/core/cluster_handlers/lighting.py @@ -29,7 +29,6 @@ class ColorClusterHandler(ClusterHandler): AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_hue", config=REPORT_CONFIG_DEFAULT), - AttrReportConfig(attr="enhanced_current_hue", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="current_saturation", config=REPORT_CONFIG_DEFAULT), AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), ) @@ -41,6 +40,7 @@ class ColorClusterHandler(ClusterHandler): "color_temp_physical_max": True, "color_capabilities": True, "color_loop_active": False, + "enhanced_current_hue": False, "start_up_color_temperature": True, "options": True, } From e6895b5738dabcdf253c758836a803e20061f87e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 17 Oct 2023 22:25:35 +0200 Subject: [PATCH 512/968] Fix menu in knx config flow (#102168) * Fix menu in knx config flow * Update tests * Fix strings.json * Rename new menu steps for readabiltiy --------- Co-authored-by: Matthias Alphart --- homeassistant/components/knx/config_flow.py | 35 +++++++++++++-------- homeassistant/components/knx/strings.json | 34 ++++++++++++++------ tests/components/knx/test_config_flow.py | 10 +++--- 3 files changed, 51 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 8e5783dc2d1..6e3da8ad523 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -237,10 +237,7 @@ class KNXCommonFlow(ABC, FlowHandler): tunnel_endpoint_ia=None, ) if connection_type == CONF_KNX_TUNNELING_TCP_SECURE: - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_tunnel_manual"], - ) + return await self.async_step_secure_key_source_menu_tunnel() self.new_title = f"Tunneling @ {self._selected_tunnel}" return self.finish_flow() @@ -317,10 +314,7 @@ class KNXCommonFlow(ABC, FlowHandler): ) if selected_tunnelling_type == CONF_KNX_TUNNELING_TCP_SECURE: - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_tunnel_manual"], - ) + return await self.async_step_secure_key_source_menu_tunnel() self.new_title = ( "Tunneling " f"{'UDP' if selected_tunnelling_type == CONF_KNX_TUNNELING else 'TCP'} " @@ -680,10 +674,7 @@ class KNXCommonFlow(ABC, FlowHandler): ) if connection_type == CONF_KNX_ROUTING_SECURE: self.new_title = f"Secure Routing as {_individual_address}" - return self.async_show_menu( - step_id="secure_key_source", - menu_options=["secure_knxkeys", "secure_routing_manual"], - ) + return await self.async_step_secure_key_source_menu_routing() self.new_title = f"Routing as {_individual_address}" return self.finish_flow() @@ -712,6 +703,24 @@ class KNXCommonFlow(ABC, FlowHandler): step_id="routing", data_schema=vol.Schema(fields), errors=errors ) + async def async_step_secure_key_source_menu_tunnel( + self, user_input: dict | None = None + ) -> FlowResult: + """Show the key source menu.""" + return self.async_show_menu( + step_id="secure_key_source_menu_tunnel", + menu_options=["secure_knxkeys", "secure_tunnel_manual"], + ) + + async def async_step_secure_key_source_menu_routing( + self, user_input: dict | None = None + ) -> FlowResult: + """Show the key source menu.""" + return self.async_show_menu( + step_id="secure_key_source_menu_routing", + menu_options=["secure_knxkeys", "secure_routing_manual"], + ) + class KNXConfigFlow(KNXCommonFlow, ConfigFlow, domain=DOMAIN): """Handle a KNX config flow.""" @@ -770,7 +779,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): ) -> FlowResult: """Manage KNX options.""" return self.async_show_menu( - step_id="options_init", + step_id="init", menu_options=[ "connection_type", "communication_settings", diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 1ff008653d4..8ffbcdf0566 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -32,12 +32,19 @@ "local_ip": "Local IP or interface name used for the connection from Home Assistant. Leave blank to use auto-discovery." } }, - "secure_key_source": { + "secure_key_source_menu_tunnel": { "title": "KNX IP-Secure", "description": "Select how you want to configure KNX/IP Secure.", "menu_options": { "secure_knxkeys": "Use a `.knxkeys` file containing IP secure keys", - "secure_tunnel_manual": "Configure IP secure credentials manually", + "secure_tunnel_manual": "Configure IP secure credentials manually" + } + }, + "secure_key_source_menu_routing": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", + "menu_options": { + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", "secure_routing_manual": "Configure IP secure backbone key manually" } }, @@ -121,7 +128,7 @@ }, "options": { "step": { - "options_init": { + "init": { "title": "KNX Settings", "menu_options": { "connection_type": "Configure KNX interface", @@ -130,7 +137,7 @@ } }, "communication_settings": { - "title": "[%key:component::knx::options::step::options_init::menu_options::communication_settings%]", + "title": "[%key:component::knx::options::step::init::menu_options::communication_settings%]", "data": { "state_updater": "State updater", "rate_limit": "Rate limit", @@ -173,13 +180,20 @@ "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } }, - "secure_key_source": { - "title": "[%key:component::knx::config::step::secure_key_source::title%]", - "description": "[%key:component::knx::config::step::secure_key_source::description%]", + "secure_key_source_menu_tunnel": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_knxkeys%]", - "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_tunnel_manual%]", - "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source::menu_options::secure_routing_manual%]" + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", + "secure_tunnel_manual": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_tunnel_manual%]" + } + }, + "secure_key_source_menu_routing": { + "title": "[%key:component::knx::config::step::secure_key_source_menu_routing::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_routing::description%]", + "menu_options": { + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_knxkeys%]", + "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" } }, "secure_knxkeys": { diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index f8200214019..5d42ed79542 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -302,7 +302,7 @@ async def test_routing_secure_manual_setup( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_routing" result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -392,7 +392,7 @@ async def test_routing_secure_keyfile( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_routing" result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], @@ -948,7 +948,7 @@ async def _get_menu_step_secure_tunnel(hass: HomeAssistant) -> FlowResult: {CONF_KNX_GATEWAY: str(gateway)}, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" return result3 @@ -1008,7 +1008,7 @@ async def test_get_secure_menu_step_manual_tunnelling( }, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" async def test_configure_secure_tunnel_manual(hass: HomeAssistant, knx_setup) -> None: @@ -1272,7 +1272,7 @@ async def test_options_flow_secure_manual_to_keyfile( {CONF_KNX_GATEWAY: str(gateway)}, ) assert result3["type"] == FlowResultType.MENU - assert result3["step_id"] == "secure_key_source" + assert result3["step_id"] == "secure_key_source_menu_tunnel" result4 = await hass.config_entries.options.async_configure( result3["flow_id"], From d8e541a28439e7e91ced97de3a639da55c23abc1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 10:53:17 -1000 Subject: [PATCH 513/968] Only compute homekit_controller accessory_info when entity is added or config changes (#102145) --- .../homekit_controller/connection.py | 41 ++- .../components/homekit_controller/entity.py | 42 +-- tests/components/homekit_controller/common.py | 7 +- .../home_assistant_bridge_basic_fan.json | 244 +++++++++++++++ .../snapshots/test_init.ambr | 286 ++++++++++++++++++ .../test_fan_that_changes_features.py | 55 ++++ 6 files changed, 642 insertions(+), 33 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json create mode 100644 tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 348dd5e7ccf..141800a0b62 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable from datetime import datetime, timedelta +from functools import partial import logging from operator import attrgetter from types import MappingProxyType @@ -144,6 +145,7 @@ class HKDevice: ) self._availability_callbacks: set[CALLBACK_TYPE] = set() + self._config_changed_callbacks: set[CALLBACK_TYPE] = set() self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} @property @@ -605,6 +607,8 @@ class HKDevice: await self.async_process_entity_map() if self.watchable_characteristics: await self.pairing.subscribe(self.watchable_characteristics) + for callback_ in self._config_changed_callbacks: + callback_() await self.async_update() await self.async_add_new_entities() @@ -805,6 +809,16 @@ class HKDevice: for callback_ in to_callback: callback_() + @callback + def _remove_characteristics_callback( + self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + ) -> None: + """Remove a characteristics callback.""" + for aid_iid in characteristics: + self._subscriptions[aid_iid].remove(callback_) + if not self._subscriptions[aid_iid]: + del self._subscriptions[aid_iid] + @callback def async_subscribe( self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE @@ -812,24 +826,31 @@ class HKDevice: """Add characteristics to the watch list.""" for aid_iid in characteristics: self._subscriptions.setdefault(aid_iid, set()).add(callback_) + return partial( + self._remove_characteristics_callback, characteristics, callback_ + ) - def _unsub(): - for aid_iid in characteristics: - self._subscriptions[aid_iid].remove(callback_) - if not self._subscriptions[aid_iid]: - del self._subscriptions[aid_iid] - - return _unsub + @callback + def _remove_availability_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._availability_callbacks.remove(callback_) @callback def async_subscribe_availability(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" self._availability_callbacks.add(callback_) + return partial(self._remove_availability_callback, callback_) - def _unsub(): - self._availability_callbacks.remove(callback_) + @callback + def _remove_config_changed_callback(self, callback_: CALLBACK_TYPE) -> None: + """Remove an availability callback.""" + self._config_changed_callbacks.remove(callback_) - return _unsub + @callback + def async_subscribe_config_changed(self, callback_: CALLBACK_TYPE) -> CALLBACK_TYPE: + """Subscribe to config of the accessory being changed aka c# changes.""" + self._config_changed_callbacks.add(callback_) + return partial(self._remove_config_changed_callback, callback_) async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: """Read latest state from homekit accessory.""" diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 6fdb450a5b4..04dabf410a4 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,15 +3,15 @@ from __future__ import annotations from typing import Any -from aiohomekit.model import Accessory from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, CharacteristicPermissions, CharacteristicsTypes, ) -from aiohomekit.model.services import Service, ServicesTypes +from aiohomekit.model.services import ServicesTypes +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -32,41 +32,43 @@ class HomeKitEntity(Entity): self._iid = devinfo["iid"] self._char_name: str | None = None self.all_characteristics: set[tuple[int, int]] = set() + self._async_set_accessory_and_service() self.setup() super().__init__() - @property - def accessory(self) -> Accessory: - """Return an Accessory model that this entity is attached to.""" - return self._accessory.entity_map.aid(self._aid) - - @property - def accessory_info(self) -> Service: - """Information about the make and model of an accessory.""" - return self.accessory.services.first( + @callback + def _async_set_accessory_and_service(self) -> None: + """Set the accessory and service for this entity.""" + accessory = self._accessory + self.accessory = accessory.entity_map.aid(self._aid) + self.service = self.accessory.services.iid(self._iid) + self.accessory_info = self.accessory.services.first( service_type=ServicesTypes.ACCESSORY_INFORMATION ) - @property - def service(self) -> Service: - """Return a Service model that this entity is attached to.""" - return self.accessory.services.iid(self._iid) + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + self._async_set_accessory_and_service() + self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Entity added to hass.""" + accessory = self._accessory self.async_on_remove( - self._accessory.async_subscribe( + accessory.async_subscribe( self.all_characteristics, self._async_write_ha_state ) ) self.async_on_remove( - self._accessory.async_subscribe_availability(self._async_write_ha_state) + accessory.async_subscribe_availability(self._async_write_ha_state) ) - self._accessory.add_pollable_characteristics(self.pollable_characteristics) - await self._accessory.add_watchable_characteristics( - self.watchable_characteristics + self.async_on_remove( + accessory.async_subscribe_config_changed(self._async_config_changed) ) + accessory.add_pollable_characteristics(self.pollable_characteristics) + await accessory.add_watchable_characteristics(self.watchable_characteristics) async def async_will_remove_from_hass(self) -> None: """Prepare to be removed from hass.""" diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 2b532769220..4fbbfea932f 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -8,6 +8,7 @@ import os from typing import Any, Final from unittest import mock +from aiohomekit.controller.abstract import AbstractPairing from aiohomekit.hkjson import loads as hkloads from aiohomekit.model import ( Accessories, @@ -180,7 +181,7 @@ async def time_changed(hass, seconds): await hass.async_block_till_done() -async def setup_accessories_from_file(hass, path): +async def setup_accessories_from_file(hass: HomeAssistant, path: str) -> Accessories: """Load an collection of accessory defs from JSON data.""" accessories_fixture = await hass.async_add_executor_job( load_fixture, os.path.join("homekit_controller", path) @@ -242,11 +243,11 @@ async def setup_test_accessories_with_controller( return config_entry, pairing -async def device_config_changed(hass, accessories): +async def device_config_changed(hass: HomeAssistant, accessories: Accessories): """Discover new devices added to Home Assistant at runtime.""" # Update the accessories our FakePairing knows about controller = hass.data[CONTROLLER] - pairing = controller.pairings["00:00:00:00:00:00"] + pairing: AbstractPairing = controller.pairings["00:00:00:00:00:00"] accessories_obj = Accessories() for accessory in accessories: diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json new file mode 100644 index 00000000000..e508a3523c4 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_basic_fan.json @@ -0,0 +1,244 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Bridge" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Home Assistant Bridge" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "homekit.bridge" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 1256851357, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Living Room Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.living_room_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationDirection", + "format": "int", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "type": "00000028-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 766313939, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Ceiling Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.ceiling_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 10, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index aa9294472f0..d02aaa1ae49 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -5544,6 +5544,292 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_basic_fan] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:766313939', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Ceiling Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.ceiling_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan Identify', + }), + 'entity_id': 'button.ceiling_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.ceiling_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Ceiling Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_766313939_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Ceiling Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.ceiling_fan', + 'state': 'off', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[home_assistant_bridge_fan] list([ dict({ diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py new file mode 100644 index 00000000000..a7750edf9aa --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -0,0 +1,55 @@ +"""Test for a Home Assistant bridge that changes fan features at runtime.""" + + +from homeassistant.components.fan import FanEntityFeature +from homeassistant.const import ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from ..common import ( + device_config_changed, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that new features can be added at runtime.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to add oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await device_config_changed(hass, accessories) + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED From e2e9c84c886726bea2ff92c15995435602b7d46f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 19:35:39 -1000 Subject: [PATCH 514/968] Cache construction of battery icon (#102194) This was being built every time state was written. When a robo vac is operating it writes state often which mean building the icon string over and over again when it rarely changes. --- homeassistant/helpers/icon.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py index a289ab4a874..97e0d20927c 100644 --- a/homeassistant/helpers/icon.py +++ b/homeassistant/helpers/icon.py @@ -1,7 +1,10 @@ """Icon helper methods.""" from __future__ import annotations +from functools import lru_cache + +@lru_cache def icon_for_battery_level( battery_level: int | None = None, charging: bool = False ) -> str: From c3d1db5db6fabe89136f0d7c1582b6f76e0e2f75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 20:49:35 -1000 Subject: [PATCH 515/968] Handle removal of accessories/services/chars in homekit_controller (#102179) --- .../homekit_controller/connection.py | 73 +- .../components/homekit_controller/const.py | 2 + .../homekit_controller/device_trigger.py | 7 +- .../components/homekit_controller/entity.py | 141 ++- .../components/homekit_controller/event.py | 16 +- tests/components/homekit_controller/common.py | 2 + .../fixtures/ecobee3_service_removed.json | 561 ++++++++++++ ...home_assistant_bridge_fan_one_removed.json | 166 ++++ .../snapshots/test_init.ambr | 829 ++++++++++++++++++ .../specific_devices/test_ecobee3.py | 88 ++ .../test_fan_that_changes_features.py | 87 ++ 11 files changed, 1907 insertions(+), 65 deletions(-) create mode 100644 tests/components/homekit_controller/fixtures/ecobee3_service_removed.json create mode 100644 tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 141800a0b62..3aac6484bed 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -29,7 +29,7 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import async_call_later, async_track_time_interval from .config_flow import normalize_hkid from .const import ( @@ -43,6 +43,7 @@ from .const import ( IDENTIFIER_LEGACY_SERIAL_NUMBER, IDENTIFIER_SERIAL_NUMBER, STARTUP_EXCEPTIONS, + SUBSCRIBE_COOLDOWN, ) from .device_trigger import async_fire_triggers, async_setup_triggers_for_entry @@ -116,7 +117,7 @@ class HKDevice: # This just tracks aid/iid pairs so we know if a HK service has been # mapped to a HA entity. - self.entities: list[tuple[int, int | None, int | None]] = [] + self.entities: set[tuple[int, int | None, int | None]] = set() # A map of aid -> device_id # Useful when routing events to triggers @@ -124,7 +125,7 @@ class HKDevice: self.available = False - self.pollable_characteristics: list[tuple[int, int]] = [] + self.pollable_characteristics: set[tuple[int, int]] = set() # Never allow concurrent polling of the same accessory or bridge self._polling_lock = asyncio.Lock() @@ -134,7 +135,7 @@ class HKDevice: # This is set to True if we can't rely on serial numbers to be unique self.unreliable_serial_numbers = False - self.watchable_characteristics: list[tuple[int, int]] = [] + self.watchable_characteristics: set[tuple[int, int]] = set() self._debounced_update = Debouncer( hass, @@ -147,6 +148,8 @@ class HKDevice: self._availability_callbacks: set[CALLBACK_TYPE] = set() self._config_changed_callbacks: set[CALLBACK_TYPE] = set() self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} + self._pending_subscribes: set[tuple[int, int]] = set() + self._subscribe_timer: CALLBACK_TYPE | None = None @property def entity_map(self) -> Accessories: @@ -162,26 +165,51 @@ class HKDevice: self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.pollable_characteristics.extend(characteristics) + self.pollable_characteristics.update(characteristics) - def remove_pollable_characteristics(self, accessory_id: int) -> None: + def remove_pollable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.pollable_characteristics = [ - char for char in self.pollable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.pollable_characteristics.discard(aid_iid) - async def add_watchable_characteristics( + def add_watchable_characteristics( self, characteristics: list[tuple[int, int]] ) -> None: """Add (aid, iid) pairs that we need to poll.""" - self.watchable_characteristics.extend(characteristics) - await self.pairing.subscribe(characteristics) + self.watchable_characteristics.update(characteristics) + self._pending_subscribes.update(characteristics) + # Try to subscribe to the characteristics all at once + if not self._subscribe_timer: + self._subscribe_timer = async_call_later( + self.hass, + SUBSCRIBE_COOLDOWN, + self._async_subscribe, + ) - def remove_watchable_characteristics(self, accessory_id: int) -> None: + @callback + def _async_cancel_subscription_timer(self) -> None: + """Cancel the subscribe timer.""" + if self._subscribe_timer: + self._subscribe_timer() + self._subscribe_timer = None + + async def _async_subscribe(self, _now: datetime) -> None: + """Subscribe to characteristics.""" + self._subscribe_timer = None + if self._pending_subscribes: + subscribes = self._pending_subscribes.copy() + self._pending_subscribes.clear() + await self.pairing.subscribe(subscribes) + + def remove_watchable_characteristics( + self, characteristics: list[tuple[int, int]] + ) -> None: """Remove all pollable characteristics by accessory id.""" - self.watchable_characteristics = [ - char for char in self.watchable_characteristics if char[0] != accessory_id - ] + for aid_iid in characteristics: + self.watchable_characteristics.discard(aid_iid) + self._pending_subscribes.discard(aid_iid) @callback def async_set_available_state(self, available: bool) -> None: @@ -264,6 +292,7 @@ class HKDevice: entry.async_on_unload( pairing.dispatcher_availability_changed(self.async_set_available_state) ) + entry.async_on_unload(self._async_cancel_subscription_timer) await self.async_process_entity_map() @@ -605,8 +634,6 @@ class HKDevice: async def async_update_new_accessories_state(self) -> None: """Process a change in the pairings accessories state.""" await self.async_process_entity_map() - if self.watchable_characteristics: - await self.pairing.subscribe(self.watchable_characteristics) for callback_ in self._config_changed_callbacks: callback_() await self.async_update() @@ -623,7 +650,7 @@ class HKDevice: if (accessory.aid, None, None) in self.entities: continue if handler(accessory): - self.entities.append((accessory.aid, None, None)) + self.entities.add((accessory.aid, None, None)) break def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None: @@ -639,7 +666,7 @@ class HKDevice: if (accessory.aid, service.iid, char.iid) in self.entities: continue if handler(char): - self.entities.append((accessory.aid, service.iid, char.iid)) + self.entities.add((accessory.aid, service.iid, char.iid)) break def add_listener(self, add_entities_cb: AddServiceCb) -> None: @@ -687,7 +714,7 @@ class HKDevice: for listener in callbacks: if listener(service): - self.entities.append((aid, None, iid)) + self.entities.add((aid, None, iid)) break async def async_load_platform(self, platform: str) -> None: @@ -811,7 +838,7 @@ class HKDevice: @callback def _remove_characteristics_callback( - self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE ) -> None: """Remove a characteristics callback.""" for aid_iid in characteristics: @@ -821,7 +848,7 @@ class HKDevice: @callback def async_subscribe( - self, characteristics: Iterable[tuple[int, int]], callback_: CALLBACK_TYPE + self, characteristics: set[tuple[int, int]], callback_: CALLBACK_TYPE ) -> CALLBACK_TYPE: """Add characteristics to the watch list.""" for aid_iid in characteristics: diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index f60dc669968..cc2c28cb5dc 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -120,3 +120,5 @@ STARTUP_EXCEPTIONS = ( # also happens to be the same value used by # the update coordinator. DEBOUNCE_COOLDOWN = 10 # seconds + +SUBSCRIBE_COOLDOWN = 0.25 # seconds diff --git a/homeassistant/components/homekit_controller/device_trigger.py b/homeassistant/components/homekit_controller/device_trigger.py index 9eab0fbb098..fa4c1c171c2 100644 --- a/homeassistant/components/homekit_controller/device_trigger.py +++ b/homeassistant/components/homekit_controller/device_trigger.py @@ -64,7 +64,8 @@ class TriggerSource: self._callbacks: dict[tuple[str, str], list[Callable[[Any], None]]] = {} self._iid_trigger_keys: dict[int, set[tuple[str, str]]] = {} - async def async_setup( + @callback + def async_setup( self, connection: HKDevice, aid: int, triggers: list[dict[str, Any]] ) -> None: """Set up a set of triggers for a device. @@ -78,7 +79,7 @@ class TriggerSource: self._triggers[trigger_key] = trigger_data iid = trigger_data["characteristic"] self._iid_trigger_keys.setdefault(iid, set()).add(trigger_key) - await connection.add_watchable_characteristics([(aid, iid)]) + connection.add_watchable_characteristics([(aid, iid)]) def fire(self, iid: int, ev: dict[str, Any]) -> None: """Process events that have been received from a HomeKit accessory.""" @@ -237,7 +238,7 @@ async def async_setup_triggers_for_entry( return False trigger = async_get_or_create_trigger_source(conn.hass, device_id) - hass.async_create_task(trigger.async_setup(conn, aid, triggers)) + trigger.async_setup(conn, aid, triggers) return True diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 04dabf410a4..a965084bdae 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from aiohomekit.model import Service, Services from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, @@ -11,7 +12,7 @@ from aiohomekit.model.characteristics import ( ) from aiohomekit.model.services import ServicesTypes -from homeassistant.core import callback +from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType @@ -20,10 +21,21 @@ from .connection import HKDevice, valid_serial_number from .utils import folded_name +def _get_service_by_iid_or_none(services: Services, iid: int) -> Service | None: + """Return a service by iid or None.""" + try: + return services.iid(iid) + except KeyError: + return None + + class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" _attr_should_poll = False + pollable_characteristics: list[tuple[int, int]] + watchable_characteristics: list[tuple[int, int]] + all_characteristics: set[tuple[int, int]] def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit device.""" @@ -31,49 +43,77 @@ class HomeKitEntity(Entity): self._aid = devinfo["aid"] self._iid = devinfo["iid"] self._char_name: str | None = None - self.all_characteristics: set[tuple[int, int]] = set() - self._async_set_accessory_and_service() - self.setup() - + self._char_subscription: CALLBACK_TYPE | None = None + self.async_setup() super().__init__() @callback - def _async_set_accessory_and_service(self) -> None: - """Set the accessory and service for this entity.""" - accessory = self._accessory - self.accessory = accessory.entity_map.aid(self._aid) - self.service = self.accessory.services.iid(self._iid) - self.accessory_info = self.accessory.services.first( - service_type=ServicesTypes.ACCESSORY_INFORMATION - ) + def _async_handle_entity_removed(self) -> None: + """Handle entity removal.""" + # We call _async_unsubscribe_chars as soon as we + # know the entity is about to be removed so we do not try to + # update characteristics that no longer exist. It will get + # called in async_will_remove_from_hass as well, but that is + # too late. + self._async_unsubscribe_chars() + self.hass.async_create_task(self.async_remove(force_remove=True)) + + @callback + def _async_remove_entity_if_accessory_or_service_disappeared(self) -> bool: + """Handle accessory or service disappearance.""" + entity_map = self._accessory.entity_map + if not entity_map.has_aid(self._aid) or not _get_service_by_iid_or_none( + entity_map.aid(self._aid).services, self._iid + ): + self._async_handle_entity_removed() + return True + return False @callback def _async_config_changed(self) -> None: """Handle accessory discovery changes.""" - self._async_set_accessory_and_service() + if not self._async_remove_entity_if_accessory_or_service_disappeared(): + self._async_reconfigure() + + @callback + def _async_reconfigure(self) -> None: + """Reconfigure the entity.""" + self._async_unsubscribe_chars() + self.async_setup() + self._async_subscribe_chars() self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Entity added to hass.""" - accessory = self._accessory + self._async_subscribe_chars() self.async_on_remove( - accessory.async_subscribe( - self.all_characteristics, self._async_write_ha_state - ) + self._accessory.async_subscribe_config_changed(self._async_config_changed) ) self.async_on_remove( - accessory.async_subscribe_availability(self._async_write_ha_state) + self._accessory.async_subscribe_availability(self._async_write_ha_state) ) - self.async_on_remove( - accessory.async_subscribe_config_changed(self._async_config_changed) - ) - accessory.add_pollable_characteristics(self.pollable_characteristics) - await accessory.add_watchable_characteristics(self.watchable_characteristics) async def async_will_remove_from_hass(self) -> None: """Prepare to be removed from hass.""" - self._accessory.remove_pollable_characteristics(self._aid) - self._accessory.remove_watchable_characteristics(self._aid) + self._async_unsubscribe_chars() + + @callback + def _async_unsubscribe_chars(self): + """Handle unsubscribing from characteristics.""" + if self._char_subscription: + self._char_subscription() + self._char_subscription = None + self._accessory.remove_pollable_characteristics(self.pollable_characteristics) + self._accessory.remove_watchable_characteristics(self.watchable_characteristics) + + @callback + def _async_subscribe_chars(self): + """Handle registering characteristics to watch and subscribe.""" + self._accessory.add_pollable_characteristics(self.pollable_characteristics) + self._accessory.add_watchable_characteristics(self.watchable_characteristics) + self._char_subscription = self._accessory.async_subscribe( + self.all_characteristics, self._async_write_ha_state + ) async def async_put_characteristics(self, characteristics: dict[str, Any]) -> None: """Write characteristics to the device. @@ -92,10 +132,22 @@ class HomeKitEntity(Entity): payload = self.service.build_update(characteristics) return await self._accessory.put_characteristics(payload) - def setup(self) -> None: + @callback + def async_setup(self) -> None: """Configure an entity based on its HomeKit characteristics metadata.""" - self.pollable_characteristics: list[tuple[int, int]] = [] - self.watchable_characteristics: list[tuple[int, int]] = [] + accessory = self._accessory + self.accessory = accessory.entity_map.aid(self._aid) + self.service = self.accessory.services.iid(self._iid) + self.accessory_info = self.accessory.services.first( + service_type=ServicesTypes.ACCESSORY_INFORMATION + ) + # If we re-setup, we need to make sure we make new + # lists since we passed them to the connection before + # and we do not want to inadvertently modify the old + # ones. + self.pollable_characteristics = [] + self.watchable_characteristics = [] + self.all_characteristics = set() char_types = self.get_characteristic_types() @@ -203,7 +255,7 @@ class AccessoryEntity(HomeKitEntity): return f"{self._accessory.unique_id}_{self._aid}" -class CharacteristicEntity(HomeKitEntity): +class BaseCharacteristicEntity(HomeKitEntity): """A HomeKit entity that is related to an single characteristic rather than a whole service. This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with @@ -217,6 +269,35 @@ class CharacteristicEntity(HomeKitEntity): self._char = char super().__init__(accessory, devinfo) + @callback + def _async_remove_entity_if_characteristics_disappeared(self) -> bool: + """Handle characteristic disappearance.""" + if ( + not self._accessory.entity_map.aid(self._aid) + .services.iid(self._iid) + .get_char_by_iid(self._char.iid) + ): + self._async_handle_entity_removed() + return True + return False + + @callback + def _async_config_changed(self) -> None: + """Handle accessory discovery changes.""" + if ( + not self._async_remove_entity_if_accessory_or_service_disappeared() + and not self._async_remove_entity_if_characteristics_disappeared() + ): + super()._async_reconfigure() + + +class CharacteristicEntity(BaseCharacteristicEntity): + """A HomeKit entity that is related to an single characteristic rather than a whole service. + + This is typically used to expose additional sensor, binary_sensor or number entities that don't belong with + the service entity. + """ + @property def old_unique_id(self) -> str: """Return the old ID of this device.""" diff --git a/homeassistant/components/homekit_controller/event.py b/homeassistant/components/homekit_controller/event.py index 9d70127f74a..86046415e35 100644 --- a/homeassistant/components/homekit_controller/event.py +++ b/homeassistant/components/homekit_controller/event.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import KNOWN_DEVICES from .connection import HKDevice -from .entity import HomeKitEntity +from .entity import BaseCharacteristicEntity INPUT_EVENT_VALUES = { InputEventValues.SINGLE_PRESS: "single_press", @@ -26,7 +26,7 @@ INPUT_EVENT_VALUES = { } -class HomeKitEventEntity(HomeKitEntity, EventEntity): +class HomeKitEventEntity(BaseCharacteristicEntity, EventEntity): """Representation of a Homekit event entity.""" _attr_should_poll = False @@ -44,10 +44,8 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): "aid": service.accessory.aid, "iid": service.iid, }, + service.characteristics_by_type[CharacteristicsTypes.INPUT_EVENT], ) - self._characteristic = service.characteristics_by_type[ - CharacteristicsTypes.INPUT_EVENT - ] self.entity_description = entity_description @@ -55,7 +53,7 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): # clamp InputEventValues for this exact device self._attr_event_types = [ INPUT_EVENT_VALUES[v] - for v in clamp_enum_to_char(InputEventValues, self._characteristic) + for v in clamp_enum_to_char(InputEventValues, self._char) ] def get_characteristic_types(self) -> list[str]: @@ -68,19 +66,19 @@ class HomeKitEventEntity(HomeKitEntity, EventEntity): self.async_on_remove( self._accessory.async_subscribe( - [(self._aid, self._characteristic.iid)], + {(self._aid, self._char.iid)}, self._handle_event, ) ) @callback def _handle_event(self): - if self._characteristic.value is None: + if self._char.value is None: # For IP backed devices the characteristic is marked as # pollable, but always returns None when polled # Make sure we don't explode if we see that edge case. return - self._trigger_event(INPUT_EVENT_VALUES[self._characteristic.value]) + self._trigger_event(INPUT_EVENT_VALUES[self._char.value]) self.async_write_ha_state() diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 4fbbfea932f..9642b18dd1c 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -26,6 +26,7 @@ from homeassistant.components.homekit_controller.const import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, IDENTIFIER_ACCESSORY_ID, + SUBSCRIBE_COOLDOWN, ) from homeassistant.components.homekit_controller.utils import async_get_controller from homeassistant.config_entries import ConfigEntry @@ -238,6 +239,7 @@ async def setup_test_accessories_with_controller( config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) + await time_changed(hass, SUBSCRIBE_COOLDOWN) await hass.async_block_till_done() return config_entry, pairing diff --git a/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json new file mode 100644 index 00000000000..ba26866939c --- /dev/null +++ b/tests/components/homekit_controller/fixtures/ecobee3_service_removed.json @@ -0,0 +1,561 @@ +[ + { + "aid": 1, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3 + }, + { + "value": "123456789012", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4 + }, + { + "value": "ecobee3", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 5 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 6 + }, + { + "value": "4.2.394", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "value": 0, + "perms": ["pr", "ev"], + "type": "A6", + "format": "uint32", + "iid": 9 + } + ], + "iid": 1 + }, + { + "type": "A2", + "characteristics": [ + { + "value": "1.1.0", + "perms": ["pr"], + "maxLen": 64, + "type": "37", + "format": "string", + "iid": 31 + } + ], + "iid": 30 + }, + { + "primary": true, + "type": "4A", + "characteristics": [ + { + "value": 1, + "maxValue": 2, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "F", + "minValue": 0, + "format": "uint8", + "iid": 17 + }, + { + "value": 1, + "maxValue": 3, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "33", + "minValue": 0, + "format": "uint8", + "iid": 18 + }, + { + "value": 21.8, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 19 + }, + { + "value": 22.2, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "35", + "minValue": 7.2, + "format": "float", + "iid": 20 + }, + { + "value": 1, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "type": "36", + "minValue": 0, + "format": "uint8", + "iid": 21 + }, + { + "value": 24.4, + "maxValue": 33.3, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "D", + "minValue": 18.3, + "format": "float", + "iid": 22 + }, + { + "value": 22.2, + "maxValue": 26.1, + "minStep": 0.1, + "perms": ["pr", "pw", "ev"], + "unit": "celsius", + "type": "12", + "minValue": 7.2, + "format": "float", + "iid": 23 + }, + { + "value": 34, + "maxValue": 100, + "minStep": 1, + "perms": ["pr", "ev"], + "unit": "percentage", + "type": "10", + "minValue": 0, + "format": "float", + "iid": 24 + }, + { + "value": 36, + "maxValue": 50, + "minStep": 1, + "perms": ["pr", "pw", "ev"], + "unit": "percentage", + "type": "34", + "minValue": 20, + "format": "float", + "iid": 25 + }, + { + "value": "HomeW", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 27 + } + ], + "iid": 16 + } + ] + }, + { + "aid": 2, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2049 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 2050 + }, + { + "value": "AB1C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 2051 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 2052 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 2053 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21.5, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 2064 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2067 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2066 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 2065 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 2060 + }, + { + "value": "Kitchen", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 2063 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 2062 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 2061 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 3620, + "format": "int", + "iid": 2059 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 3, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3073 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 3074 + }, + { + "value": "AB2C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 3075 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 3076 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 3077 + } + ], + "iid": 1 + }, + { + "type": "8A", + "characteristics": [ + { + "value": 21, + "maxValue": 100, + "minStep": 0.1, + "perms": ["pr", "ev"], + "unit": "celsius", + "type": "11", + "minValue": 0, + "format": "float", + "iid": 3088 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3091 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3090 + }, + { + "value": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "minValue": 0, + "format": "uint8", + "iid": 3089 + } + ], + "iid": 55 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 3084 + }, + { + "value": "Porch", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 3087 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 3086 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 3085 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5766, + "format": "int", + "iid": 3083 + } + ], + "iid": 56 + } + ] + }, + { + "aid": 4, + "services": [ + { + "type": "3E", + "characteristics": [ + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4097 + }, + { + "value": "ecobee Inc.", + "perms": ["pr"], + "type": "20", + "format": "string", + "iid": 4098 + }, + { + "value": "AB3C", + "perms": ["pr"], + "type": "30", + "format": "string", + "iid": 4099 + }, + { + "value": "REMOTE SENSOR", + "perms": ["pr"], + "type": "21", + "format": "string", + "iid": 4100 + }, + { + "value": "1.0.0", + "perms": ["pr"], + "type": "52", + "format": "string", + "iid": 8 + }, + { + "perms": ["pw"], + "type": "14", + "format": "bool", + "iid": 4101 + } + ], + "iid": 1 + }, + { + "type": "85", + "characteristics": [ + { + "value": false, + "perms": ["pr", "ev"], + "type": "22", + "format": "bool", + "iid": 4108 + }, + { + "value": "Basement", + "perms": ["pr"], + "type": "23", + "format": "string", + "iid": 4111 + }, + { + "value": true, + "perms": ["pr", "ev"], + "type": "75", + "format": "bool", + "iid": 4110 + }, + { + "minValue": 0, + "maxValue": 1, + "minStep": 1, + "perms": ["pr", "ev"], + "type": "79", + "value": 0, + "format": "uint8", + "iid": 4109 + }, + { + "minValue": -1, + "maxValue": 86400, + "perms": ["pr", "ev"], + "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66", + "value": 5472, + "format": "int", + "iid": 4107 + } + ], + "iid": 56 + } + ] + } +] diff --git a/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json new file mode 100644 index 00000000000..f7aaab11384 --- /dev/null +++ b/tests/components/homekit_controller/fixtures/home_assistant_bridge_fan_one_removed.json @@ -0,0 +1,166 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Bridge" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Home Assistant Bridge" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "homekit.bridge" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + } + ] + }, + { + "aid": 1256851357, + "services": [ + { + "characteristics": [ + { + "description": "Identify", + "format": "bool", + "iid": 2, + "perms": ["pw"], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "description": "Manufacturer", + "format": "string", + "iid": 3, + "perms": ["pr"], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "Home Assistant" + }, + { + "description": "Model", + "format": "string", + "iid": 4, + "perms": ["pr"], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "Fan" + }, + { + "description": "Name", + "format": "string", + "iid": 5, + "perms": ["pr"], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Living Room Fan" + }, + { + "description": "SerialNumber", + "format": "string", + "iid": 6, + "perms": ["pr"], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "fan.living_room_fan" + }, + { + "description": "FirmwareRevision", + "format": "string", + "iid": 7, + "perms": ["pr"], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "0.104.0.dev0" + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "description": "Active", + "format": "uint8", + "iid": 9, + "perms": ["pr", "pw", "ev"], + "type": "000000B0-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationDirection", + "format": "int", + "iid": 10, + "perms": ["pr", "pw", "ev"], + "type": "00000028-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "SwingMode", + "format": "uint8", + "iid": 11, + "perms": ["pr", "pw", "ev"], + "type": "000000B6-0000-1000-8000-0026BB765291", + "valid-values": [0, 1], + "value": 0 + }, + { + "description": "RotationSpeed", + "format": "float", + "iid": 12, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": ["pr", "pw", "ev"], + "type": "00000029-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 100 + } + ], + "iid": 8, + "stype": "fanv2", + "type": "000000B7-0000-1000-8000-0026BB765291" + } + ] + } +] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index d02aaa1ae49..1517862664d 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -3935,6 +3935,656 @@ }), ]) # --- +# name: test_snapshots[ecobee3_service_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:4', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Basement', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.basement', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Basement', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Basement', + }), + 'entity_id': 'binary_sensor.basement', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.basement_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Basement Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_4_1_4101', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Basement Identify', + }), + 'entity_id': 'button.basement_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'ecobee3', + 'name': 'HomeW', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '4.2.394', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.homew_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_6', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Identify', + }), + 'entity_id': 'button.homew_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.homew', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HomeW', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'current_humidity': 34, + 'current_temperature': 21.8, + 'friendly_name': 'HomeW', + 'humidity': 36, + 'hvac_action': , + 'hvac_modes': list([ + , + , + , + , + ]), + 'max_humidity': 50, + 'max_temp': 33.3, + 'min_humidity': 20, + 'min_temp': 7.2, + 'supported_features': , + 'target_temp_high': None, + 'target_temp_low': None, + 'temperature': 22.2, + }), + 'entity_id': 'climate.homew', + 'state': 'heat', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'select', + 'entity_category': , + 'entity_id': 'select.homew_temperature_display_units', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:thermometer', + 'original_name': 'HomeW Temperature Display Units', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'temperature_display_units', + 'unique_id': '00:00:00:00:00:00_1_16_21', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'HomeW Temperature Display Units', + 'icon': 'mdi:thermometer', + 'options': list([ + 'celsius', + 'fahrenheit', + ]), + }), + 'entity_id': 'select.homew_temperature_display_units', + 'state': 'fahrenheit', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_humidity', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Humidity', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_24', + 'unit_of_measurement': '%', + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'humidity', + 'friendly_name': 'HomeW Current Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'entity_id': 'sensor.homew_current_humidity', + 'state': '34', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.homew_current_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'HomeW Current Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_16_19', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'HomeW Current Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.homew_current_temperature', + 'state': '21.8', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:2', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Kitchen', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.kitchen', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Kitchen', + }), + 'entity_id': 'binary_sensor.kitchen', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.kitchen_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Kitchen Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_1_2053', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Kitchen Identify', + }), + 'entity_id': 'button.kitchen_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.kitchen_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Kitchen Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_2_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Kitchen Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.kitchen_temperature', + 'state': '21.5', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:3', + ]), + ]), + 'is_new': False, + 'manufacturer': 'ecobee Inc.', + 'model': 'REMOTE SENSOR', + 'name': 'Porch', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '1.0.0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.porch', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_56', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'Porch', + }), + 'entity_id': 'binary_sensor.porch', + 'state': 'off', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.porch_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Porch Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_1_3077', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Porch Identify', + }), + 'entity_id': 'button.porch_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.porch_temperature', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Porch Temperature', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_3_55', + 'unit_of_measurement': , + }), + 'state': dict({ + 'attributes': dict({ + 'device_class': 'temperature', + 'friendly_name': 'Porch Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'entity_id': 'sensor.porch_temperature', + 'state': '21', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[ecobee_501] list([ dict({ @@ -6117,6 +6767,185 @@ }), ]) # --- +# name: test_snapshots[home_assistant_bridge_fan_one_removed] + list([ + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Bridge', + 'name': 'Home Assistant Bridge', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.home_assistant_bridge_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Home Assistant Bridge Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Home Assistant Bridge Identify', + }), + 'entity_id': 'button.home_assistant_bridge_identify', + 'state': 'unknown', + }), + }), + ]), + }), + dict({ + 'device': dict({ + 'area_id': None, + 'config_entries': list([ + 'TestData', + ]), + 'configuration_url': None, + 'connections': list([ + ]), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': '', + 'identifiers': list([ + list([ + 'homekit_controller:accessory-id', + '00:00:00:00:00:00:aid:1256851357', + ]), + ]), + 'is_new': False, + 'manufacturer': 'Home Assistant', + 'model': 'Fan', + 'name': 'Living Room Fan', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': '0.104.0.dev0', + }), + 'entities': list([ + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.living_room_fan_identify', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan Identify', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_1_2', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'Living Room Fan Identify', + }), + 'entity_id': 'button.living_room_fan_identify', + 'state': 'unknown', + }), + }), + dict({ + 'entry': dict({ + 'aliases': list([ + ]), + 'area_id': None, + 'capabilities': dict({ + 'preset_modes': None, + }), + 'config_entry_id': 'TestData', + 'device_class': None, + 'disabled_by': None, + 'domain': 'fan', + 'entity_category': None, + 'entity_id': 'fan.living_room_fan', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Living Room Fan', + 'platform': 'homekit_controller', + 'previous_unique_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00:00:00:00:00:00_1256851357_8', + 'unit_of_measurement': None, + }), + 'state': dict({ + 'attributes': dict({ + 'direction': 'forward', + 'friendly_name': 'Living Room Fan', + 'oscillating': False, + 'percentage': 0, + 'percentage_step': 1.0, + 'preset_mode': None, + 'preset_modes': None, + 'supported_features': , + }), + 'entity_id': 'fan.living_room_fan', + 'state': 'off', + }), + }), + ]), + }), + ]) +# --- # name: test_snapshots[homespan_daikin_bridge] list([ dict({ diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 1cdd4ccb907..eefb3124d0a 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -252,3 +252,91 @@ async def test_ecobee3_add_sensors_at_runtime(hass: HomeAssistant) -> None: occ3 = entity_registry.async_get("binary_sensor.basement") assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + +async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: + """Test that sensors are automatically removed.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + assert hass.states.get("binary_sensor.kitchen") is not None + assert hass.states.get("binary_sensor.porch") is not None + assert hass.states.get("binary_sensor.basement") is not None + + # Now remove 3 new sensors at runtime - sensors should disappear and climate + # shouldn't be duplicated. + accessories = await setup_accessories_from_file(hass, "ecobee3_no_sensors.json") + await device_config_changed(hass, accessories) + + assert hass.states.get("binary_sensor.kitchen") is None + assert hass.states.get("binary_sensor.porch") is None + assert hass.states.get("binary_sensor.basement") is None + + # Now add the sensors back + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await device_config_changed(hass, accessories) + + occ1 = entity_registry.async_get("binary_sensor.kitchen") + assert occ1.unique_id == "00:00:00:00:00:00_2_56" + + occ2 = entity_registry.async_get("binary_sensor.porch") + assert occ2.unique_id == "00:00:00:00:00:00_3_56" + + occ3 = entity_registry.async_get("binary_sensor.basement") + assert occ3.unique_id == "00:00:00:00:00:00_4_56" + + # Currently it is not possible to add the entities back once + # they are removed because _add_new_entities has a guard to prevent + # the same entity from being added twice. + + +async def test_ecobee3_services_and_chars_removed( + hass: HomeAssistant, +) -> None: + """Test handling removal of some services and chars.""" + entity_registry = er.async_get(hass) + + # Set up a base Ecobee 3 with additional sensors. + accessories = await setup_accessories_from_file(hass, "ecobee3.json") + await setup_test_accessories(hass, accessories) + + climate = entity_registry.async_get("climate.homew") + assert climate.unique_id == "00:00:00:00:00:00_1_16" + + assert hass.states.get("sensor.basement_temperature") is not None + assert hass.states.get("sensor.kitchen_temperature") is not None + assert hass.states.get("sensor.porch_temperature") is not None + + assert hass.states.get("select.homew_current_mode") is not None + assert hass.states.get("button.homew_clear_hold") is not None + + # Reconfigure with some of the chars removed and the basement temperature sensor + accessories = await setup_accessories_from_file( + hass, "ecobee3_service_removed.json" + ) + await device_config_changed(hass, accessories) + + # Make sure the climate entity is still there + assert hass.states.get("climate.homew") is not None + + # Make sure the basement temperature sensor is gone + assert hass.states.get("sensor.basement_temperature") is None + + # Make sure the current mode select and clear hold button are gone + assert hass.states.get("select.homew_current_mode") is None + assert hass.states.get("button.homew_clear_hold") is None diff --git a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py index a7750edf9aa..bae0c0e4ff1 100644 --- a/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py +++ b/tests/components/homekit_controller/specific_devices/test_fan_that_changes_features.py @@ -53,3 +53,90 @@ async def test_fan_add_feature_at_runtime(hass: HomeAssistant) -> None: ) fan_state = hass.states.get("fan.ceiling_fan") assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_fan_remove_feature_at_runtime(hass: HomeAssistant) -> None: + """Test that features can be removed at runtime.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to add oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_basic_fan.json" + ) + await device_config_changed(hass, accessories) + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION + ) + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + +async def test_bridge_with_two_fans_one_removed(hass: HomeAssistant) -> None: + """Test a bridge with two fans and one gets removed.""" + entity_registry = er.async_get(hass) + + # Set up a basic fan that does not support oscillation + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan.json" + ) + await setup_test_accessories(hass, accessories) + + fan = entity_registry.async_get("fan.living_room_fan") + assert fan.unique_id == "00:00:00:00:00:00_1256851357_8" + + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + + fan = entity_registry.async_get("fan.ceiling_fan") + assert fan.unique_id == "00:00:00:00:00:00_766313939_8" + + fan_state = hass.states.get("fan.ceiling_fan") + assert fan_state.attributes[ATTR_SUPPORTED_FEATURES] is FanEntityFeature.SET_SPEED + + # Now change the config to remove one of the fans + accessories = await setup_accessories_from_file( + hass, "home_assistant_bridge_fan_one_removed.json" + ) + await device_config_changed(hass, accessories) + + # Verify the first fan is still there + fan_state = hass.states.get("fan.living_room_fan") + assert ( + fan_state.attributes[ATTR_SUPPORTED_FEATURES] + is FanEntityFeature.SET_SPEED + | FanEntityFeature.DIRECTION + | FanEntityFeature.OSCILLATE + ) + # The second fan should have been removed + assert not hass.states.get("fan.ceiling_fan") From cfb88766c7b49af587f26555de819215a381307d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 21:31:40 -1000 Subject: [PATCH 516/968] Bump aioesphomeapi to 18.0.6 (#102195) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index d06cf1e00d3..dbcd2042b5a 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.4", + "aioesphomeapi==18.0.6", "bluetooth-data-tools==1.12.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index c1ec8ece5ac..ff9c5974278 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.4 +aioesphomeapi==18.0.6 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1810e135cbe..b66040b85c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.4 +aioesphomeapi==18.0.6 # homeassistant.components.flo aioflo==2021.11.0 From 3cedfbcc661c5accbf7ca325c28dc7607cbe8d14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 17 Oct 2023 22:00:02 -1000 Subject: [PATCH 517/968] Handle re-adding of accessories/services/chars in homekit_controller after removal (#102192) --- .../homekit_controller/connection.py | 38 +++++++++++-------- .../components/homekit_controller/entity.py | 11 +++++- tests/components/homekit_controller/common.py | 26 +++++-------- .../specific_devices/test_ecobee3.py | 7 ++-- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 3aac6484bed..48bc3822001 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -103,7 +103,7 @@ class HKDevice: # Track aid/iid pairs so we know if we already handle triggers for a HK # service. - self._triggers: list[tuple[int, int]] = [] + self._triggers: set[tuple[int, int]] = set() # A list of callbacks that turn HK characteristics into entities self.char_factories: list[AddCharacteristicCb] = [] @@ -639,18 +639,25 @@ class HKDevice: await self.async_update() await self.async_add_new_entities() - def add_accessory_factory(self, add_entities_cb) -> None: + @callback + def async_entity_key_removed(self, entity_key: tuple[int, int | None, int | None]): + """Handle an entity being removed. + + Releases the entity from self.entities so it can be added again. + """ + self.entities.discard(entity_key) + + def add_accessory_factory(self, add_entities_cb: AddAccessoryCb) -> None: """Add a callback to run when discovering new entities for accessories.""" self.accessory_factories.append(add_entities_cb) self._add_new_entities_for_accessory([add_entities_cb]) - def _add_new_entities_for_accessory(self, handlers) -> None: + def _add_new_entities_for_accessory(self, handlers: list[AddAccessoryCb]) -> None: for accessory in self.entity_map.accessories: + entity_key = (accessory.aid, None, None) for handler in handlers: - if (accessory.aid, None, None) in self.entities: - continue - if handler(accessory): - self.entities.add((accessory.aid, None, None)) + if entity_key not in self.entities and handler(accessory): + self.entities.add(entity_key) break def add_char_factory(self, add_entities_cb: AddCharacteristicCb) -> None: @@ -662,11 +669,10 @@ class HKDevice: for accessory in self.entity_map.accessories: for service in accessory.services: for char in service.characteristics: + entity_key = (accessory.aid, service.iid, char.iid) for handler in handlers: - if (accessory.aid, service.iid, char.iid) in self.entities: - continue - if handler(char): - self.entities.add((accessory.aid, service.iid, char.iid)) + if entity_key not in self.entities and handler(char): + self.entities.add(entity_key) break def add_listener(self, add_entities_cb: AddServiceCb) -> None: @@ -692,7 +698,7 @@ class HKDevice: for add_trigger_cb in callbacks: if add_trigger_cb(service): - self._triggers.append(entity_key) + self._triggers.add(entity_key) break def add_entities(self) -> None: @@ -702,19 +708,19 @@ class HKDevice: self._add_new_entities_for_char(self.char_factories) self._add_new_triggers(self.trigger_factories) - def _add_new_entities(self, callbacks) -> None: + def _add_new_entities(self, callbacks: list[AddServiceCb]) -> None: for accessory in self.entity_map.accessories: aid = accessory.aid for service in accessory.services: - iid = service.iid + entity_key = (aid, None, service.iid) - if (aid, None, iid) in self.entities: + if entity_key in self.entities: # Don't add the same entity again continue for listener in callbacks: if listener(service): - self.entities.add((aid, None, iid)) + self.entities.add(entity_key) break async def async_load_platform(self, platform: str) -> None: diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index a965084bdae..f8566f10b0d 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -40,8 +40,13 @@ class HomeKitEntity(Entity): def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit device.""" self._accessory = accessory - self._aid = devinfo["aid"] - self._iid = devinfo["iid"] + self._aid: int = devinfo["aid"] + self._iid: int = devinfo["iid"] + self._entity_key: tuple[int, int | None, int | None] = ( + self._aid, + None, + self._iid, + ) self._char_name: str | None = None self._char_subscription: CALLBACK_TYPE | None = None self.async_setup() @@ -96,6 +101,7 @@ class HomeKitEntity(Entity): async def async_will_remove_from_hass(self) -> None: """Prepare to be removed from hass.""" self._async_unsubscribe_chars() + self._accessory.async_entity_key_removed(self._entity_key) @callback def _async_unsubscribe_chars(self): @@ -268,6 +274,7 @@ class BaseCharacteristicEntity(HomeKitEntity): """Initialise a generic single characteristic HomeKit entity.""" self._char = char super().__init__(accessory, devinfo) + self._entity_key = (self._aid, self._iid, char.iid) @callback def _async_remove_entity_if_characteristics_disappeared(self) -> bool: diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 9642b18dd1c..c3e6b5505d3 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -8,7 +8,7 @@ import os from typing import Any, Final from unittest import mock -from aiohomekit.controller.abstract import AbstractPairing +from aiohomekit.controller.abstract import AbstractDescription, AbstractPairing from aiohomekit.hkjson import loads as hkloads from aiohomekit.model import ( Accessories, @@ -17,7 +17,6 @@ from aiohomekit.model import ( mixin as model_mixin, ) from aiohomekit.testing import FakeController, FakePairing -from aiohomekit.zeroconf import HomeKitService from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.homekit_controller.const import ( @@ -254,26 +253,21 @@ async def device_config_changed(hass: HomeAssistant, accessories: Accessories): accessories_obj = Accessories() for accessory in accessories: accessories_obj.add_accessory(accessory) - pairing._accessories_state = AccessoriesState( - accessories_obj, pairing.config_num + 1 - ) + + new_config_num = pairing.config_num + 1 pairing._async_description_update( - HomeKitService( - name="TestDevice.local", + AbstractDescription( + name="testdevice.local.", id="00:00:00:00:00:00", - model="", - config_num=2, - state_num=3, - feature_flags=0, status_flags=0, + config_num=new_config_num, category=1, - protocol_version="1.0", - type="_hap._tcp.local.", - address="127.0.0.1", - addresses=["127.0.0.1"], - port=8080, ) ) + # Set the accessories state only after calling + # _async_description_update, otherwise the config_num will be + # overwritten + pairing._accessories_state = AccessoriesState(accessories_obj, new_config_num) # Wait for services to reconfigure await hass.async_block_till_done() diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index eefb3124d0a..7b721e76bba 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -300,9 +300,10 @@ async def test_ecobee3_remove_sensors_at_runtime(hass: HomeAssistant) -> None: occ3 = entity_registry.async_get("binary_sensor.basement") assert occ3.unique_id == "00:00:00:00:00:00_4_56" - # Currently it is not possible to add the entities back once - # they are removed because _add_new_entities has a guard to prevent - # the same entity from being added twice. + # Ensure the sensors are back + assert hass.states.get("binary_sensor.kitchen") is not None + assert hass.states.get("binary_sensor.porch") is not None + assert hass.states.get("binary_sensor.basement") is not None async def test_ecobee3_services_and_chars_removed( From 6c1bcae2912cb9c175d49211eca0f5067408011c Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 18 Oct 2023 10:56:48 +0200 Subject: [PATCH 518/968] Bump aiovodafone to 0.4.1 (#102180) --- .../vodafone_station/config_flow.py | 6 +++-- .../vodafone_station/coordinator.py | 4 +-- .../components/vodafone_station/manifest.json | 2 +- .../components/vodafone_station/sensor.py | 23 ++++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../vodafone_station/test_config_flow.py | 26 +++++++++---------- 7 files changed, 33 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/vodafone_station/config_flow.py b/homeassistant/components/vodafone_station/config_flow.py index 45bb263d371..dc33d0db52b 100644 --- a/homeassistant/components/vodafone_station/config_flow.py +++ b/homeassistant/components/vodafone_station/config_flow.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from aiovodafone import VodafoneStationApi, exceptions as aiovodafone_exceptions +from aiovodafone import VodafoneStationSercommApi, exceptions as aiovodafone_exceptions import voluptuous as vol from homeassistant import core @@ -35,7 +35,9 @@ async def validate_input( ) -> dict[str, str]: """Validate the user input allows us to connect.""" - api = VodafoneStationApi(data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD]) + api = VodafoneStationSercommApi( + data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] + ) try: await api.login() diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index fe1ff1889d5..38fc80ac3af 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any -from aiovodafone import VodafoneStationApi, VodafoneStationDevice, exceptions +from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME from homeassistant.core import HomeAssistant @@ -48,7 +48,7 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Initialize the scanner.""" self._host = host - self.api = VodafoneStationApi(host, username, password) + self.api = VodafoneStationSercommApi(host, username, password) # Last resort as no MAC or S/N can be retrieved via API self._id = config_entry_unique_id diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index d37fed9564f..628c25b987e 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.3.1"] + "requirements": ["aiovodafone==0.4.1"] } diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py index ce2d3154de3..1bda3b1595d 100644 --- a/homeassistant/components/vodafone_station/sensor.py +++ b/homeassistant/components/vodafone_station/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime from typing import Any, Final from homeassistant.components.sensor import ( @@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.dt import utcnow from .const import _LOGGER, DOMAIN, LINE_TYPES from .coordinator import VodafoneStationRouter @@ -29,7 +28,9 @@ NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] class VodafoneStationBaseEntityDescription: """Vodafone Station entity base description.""" - value: Callable[[Any, Any], Any] = lambda val, key: val[key] + value: Callable[ + [Any, Any], Any + ] = lambda coordinator, key: coordinator.data.sensors[key] is_suitable: Callable[[dict], bool] = lambda val: True @@ -40,18 +41,16 @@ class VodafoneStationEntityDescription( """Vodafone Station entity description.""" -def _calculate_uptime(value: dict, key: str) -> datetime: +def _calculate_uptime(coordinator: VodafoneStationRouter, key: str) -> datetime: """Calculate device uptime.""" - d = int(value[key].split(":")[0]) - h = int(value[key].split(":")[1]) - m = int(value[key].split(":")[2]) - return utcnow() - timedelta(days=d, hours=h, minutes=m) + return coordinator.api.convert_uptime(coordinator.data.sensors[key]) -def _line_connection(value: dict, key: str) -> str | None: +def _line_connection(coordinator: VodafoneStationRouter, key: str) -> str | None: """Identify line type.""" + value = coordinator.data.sensors internet_ip = value[key] dsl_ip = value.get("dsl_ipaddr") fiber_ip = value.get("fiber_ipaddr") @@ -141,7 +140,7 @@ SENSOR_TYPES: Final = ( icon="mdi:chip", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - value=lambda value, key: float(value[key][:-1]), + value=lambda coordinator, key: float(coordinator.data.sensors[key][:-1]), ), VodafoneStationEntityDescription( key="sys_memory_usage", @@ -149,7 +148,7 @@ SENSOR_TYPES: Final = ( icon="mdi:memory", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, - value=lambda value, key: float(value[key][:-1]), + value=lambda coordinator, key: float(coordinator.data.sensors[key][:-1]), ), VodafoneStationEntityDescription( key="sys_reboot_cause", @@ -200,5 +199,5 @@ class VodafoneStationSensorEntity( def native_value(self) -> StateType: """Sensor value.""" return self.entity_description.value( - self.coordinator.data.sensors, self.entity_description.key + self.coordinator, self.entity_description.key ) diff --git a/requirements_all.txt b/requirements_all.txt index ff9c5974278..27e5886234c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aiounifi==63 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.1 +aiovodafone==0.4.1 # homeassistant.components.waqi aiowaqi==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b66040b85c1..65404fb0b22 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aiounifi==63 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.3.1 +aiovodafone==0.4.1 # homeassistant.components.waqi aiowaqi==2.0.0 diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 41efd8af00c..982a14a80f4 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -18,9 +18,9 @@ from tests.common import MockConfigEntry async def test_user(hass: HomeAssistant) -> None: """Test starting a flow by user.""" with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ) as mock_setup_entry, patch( @@ -67,7 +67,7 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> assert result["step_id"] == "user" with patch( - "aiovodafone.api.VodafoneStationApi.login", + "aiovodafone.api.VodafoneStationSercommApi.login", side_effect=side_effect, ): result = await hass.config_entries.flow.async_configure( @@ -80,15 +80,15 @@ async def test_exception_connection(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", }, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): @@ -118,9 +118,9 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: mock_config.add_to_hass(hass) with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ), patch( @@ -165,10 +165,10 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> mock_config.add_to_hass(hass) with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", side_effect=side_effect, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): @@ -194,15 +194,15 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> # Should be recoverable after hits error with patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.get_devices_data", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.get_devices_data", return_value={ "wifi_user": "on|laptop|device-1|xx:xx:xx:xx:xx:xx|192.168.100.1||2.4G", "ethernet": "laptop|device-2|yy:yy:yy:yy:yy:yy|192.168.100.2|;", }, ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.login", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.login", ), patch( - "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.logout", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationSercommApi.logout", ), patch( "homeassistant.components.vodafone_station.async_setup_entry" ): From 42f830600c210bed24b71d1bd20b792438dc2f38 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 18 Oct 2023 13:24:57 +0200 Subject: [PATCH 519/968] Bump aiowaqi to 2.1.0 (#102209) --- homeassistant/components/waqi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index a866dc2c902..1cac5be375b 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["aiowaqi"], - "requirements": ["aiowaqi==2.0.0"] + "requirements": ["aiowaqi==2.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 27e5886234c..8eff0d9e307 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -378,7 +378,7 @@ aiovlc==0.1.0 aiovodafone==0.4.1 # homeassistant.components.waqi -aiowaqi==2.0.0 +aiowaqi==2.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65404fb0b22..314a8b15398 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -353,7 +353,7 @@ aiovlc==0.1.0 aiovodafone==0.4.1 # homeassistant.components.waqi -aiowaqi==2.0.0 +aiowaqi==2.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 From a06d8c4d3f7bc40c461fc7c5b8f315506883b0a5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 18 Oct 2023 13:38:10 +0200 Subject: [PATCH 520/968] Update mypy to 1.6.1 (#102210) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index d2bdbf30b71..5805ba4d03e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ astroid==3.0.0 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.6.0 +mypy==1.6.1 pre-commit==3.5.0 pydantic==1.10.12 pylint==3.0.1 From 43aaf78f7bd3f77f2f1f5153fd182501a307e40e Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 18 Oct 2023 14:05:16 +0200 Subject: [PATCH 521/968] Fix DSMR max current device class (#102219) Max current is CURRENT Device Class The max current is shown as Amps. So need to change the device class to CURRENT instead of POWER. --- homeassistant/components/dsmr/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e4f9d0e9ab9..5bd5138a7d6 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -325,7 +325,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( translation_key="max_current_per_phase", obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, dsmr_versions={"5B"}, - device_class=SensorDeviceClass.POWER, + device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, From a65ad37c8bbfae31f0da27cc5813f0ab0cb5386a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 18 Oct 2023 14:27:37 +0200 Subject: [PATCH 522/968] Change config entry title to Picnic (#102221) --- homeassistant/components/picnic/config_flow.py | 2 +- homeassistant/components/picnic/sensor.py | 2 -- tests/components/picnic/test_config_flow.py | 2 +- tests/components/picnic/test_sensor.py | 2 +- 4 files changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index 904b68e3d32..65ae201482a 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -107,7 +107,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Abort if we're adding a new config and the unique id is already in use, else create the entry if self.source != SOURCE_REAUTH: self._abort_if_unique_id_configured() - return self.async_create_entry(title=info["title"], data=data) + return self.async_create_entry(title="Picnic", data=data) # In case of re-auth, only continue if an exiting account exists with the same unique id if existing_entry: diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index 6e35c27bbfb..fb4e756b1be 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -24,7 +24,6 @@ from homeassistant.helpers.update_coordinator import ( from homeassistant.util import dt as dt_util from .const import ( - ADDRESS, ATTRIBUTION, CONF_COORDINATOR, DOMAIN, @@ -263,7 +262,6 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, manufacturer="Picnic", model=config_entry.unique_id, - name=f"Picnic: {coordinator.data[ADDRESS]}", ) @property diff --git a/tests/components/picnic/test_config_flow.py b/tests/components/picnic/test_config_flow.py index 95ff762ef72..a649240bd21 100644 --- a/tests/components/picnic/test_config_flow.py +++ b/tests/components/picnic/test_config_flow.py @@ -59,7 +59,7 @@ async def test_form(hass: HomeAssistant, picnic_api) -> None: await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "Teststreet 123b" + assert result2["title"] == "Picnic" assert result2["data"] == { CONF_ACCESS_TOKEN: picnic_api().session.auth_token, CONF_COUNTRY_CODE: "NL", diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index c47226d407e..6d5a56499b9 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -502,7 +502,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): identifiers={(const.DOMAIN, DEFAULT_USER_RESPONSE["user_id"])} ) assert picnic_service.model == DEFAULT_USER_RESPONSE["user_id"] - assert picnic_service.name == "Picnic: Commonstreet 123a" + assert picnic_service.name == "Mock Title" assert picnic_service.entry_type is dr.DeviceEntryType.SERVICE async def test_auth_token_is_saved_on_update(self): From 799342497b89b5f72f629c02fec3387ba39583c3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 18 Oct 2023 14:52:51 +0200 Subject: [PATCH 523/968] Remove instances of title case in common strings (#102212) --- homeassistant/strings.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/strings.json b/homeassistant/strings.json index 871e1b4ecbc..f41380fc9e5 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -67,8 +67,8 @@ }, "config_flow": { "title": { - "oauth2_pick_implementation": "Pick Authentication Method", - "reauth": "Reauthenticate Integration", + "oauth2_pick_implementation": "Pick authentication method", + "reauth": "Reauthenticate integration", "via_hassio_addon": "{name} via Home Assistant add-on" }, "description": { @@ -81,20 +81,20 @@ "username": "Username", "password": "Password", "host": "Host", - "ip": "IP Address", + "ip": "IP address", "port": "Port", "url": "URL", - "usb_path": "USB Device Path", - "access_token": "Access Token", - "api_key": "API Key", - "api_token": "API Token", + "usb_path": "USB device path", + "access_token": "Access token", + "api_key": "API key", + "api_token": "API token", "ssl": "Uses an SSL certificate", "verify_ssl": "Verify SSL certificate", "elevation": "Elevation", "longitude": "Longitude", "latitude": "Latitude", "location": "Location", - "pin": "PIN Code", + "pin": "PIN code", "mode": "Mode", "path": "Path" }, From 5f35eecf935b0b6617174347d756874786847ce0 Mon Sep 17 00:00:00 2001 From: Philippe Wechsler <29612400+MadMonkey87@users.noreply.github.com> Date: Wed, 18 Oct 2023 16:01:46 +0200 Subject: [PATCH 524/968] Add sensors for myStrom plugs (#97024) * support sensors for myStrom plugs * added myStrom sensor to coveragerc * some improvements from pr reviews * adapt to the SensorEntityDescription pattern * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/mystrom/sensor.py Co-authored-by: G Johansson * Update homeassistant/components/mystrom/sensor.py Co-authored-by: Joost Lekkerkerker * Update __init__.py * Update const.py * Update sensor.py * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: G Johansson --- .coveragerc | 1 + homeassistant/components/mystrom/__init__.py | 6 +- homeassistant/components/mystrom/sensor.py | 92 ++++++++++++++++++++ 3 files changed, 96 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/mystrom/sensor.py diff --git a/.coveragerc b/.coveragerc index e12222594ac..29f93c04da6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -789,6 +789,7 @@ omit = homeassistant/components/mystrom/binary_sensor.py homeassistant/components/mystrom/light.py homeassistant/components/mystrom/switch.py + homeassistant/components/mystrom/sensor.py homeassistant/components/nad/media_player.py homeassistant/components/nanoleaf/__init__.py homeassistant/components/nanoleaf/button.py diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 3166c05db19..96bc49ca853 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .models import MyStromData -PLATFORMS_SWITCH = [Platform.SWITCH] +PLATFORMS_PLUGS = [Platform.SWITCH, Platform.SENSOR] PLATFORMS_BULB = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) @@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_type = info["type"] if device_type in [101, 106, 107, 120]: device = _get_mystrom_switch(host) - platforms = PLATFORMS_SWITCH + platforms = PLATFORMS_PLUGS await _async_get_device_state(device, info["ip"]) elif device_type in [102, 105]: mac = info["mac"] @@ -87,7 +87,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_type = hass.data[DOMAIN][entry.entry_id].info["type"] platforms = [] if device_type in [101, 106, 107, 120]: - platforms.extend(PLATFORMS_SWITCH) + platforms.extend(PLATFORMS_PLUGS) elif device_type in [102, 105]: platforms.extend(PLATFORMS_BULB) if unload_ok := await hass.config_entries.async_unload_platforms(entry, platforms): diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py new file mode 100644 index 00000000000..982528bd97c --- /dev/null +++ b/homeassistant/components/mystrom/sensor.py @@ -0,0 +1,92 @@ +"""Support for myStrom sensors of switches/plugs.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from pymystrom.switch import MyStromSwitch + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, MANUFACTURER + + +@dataclass +class MyStromSwitchSensorEntityDescription(SensorEntityDescription): + """Class describing mystrom switch sensor entities.""" + + value_fn: Callable[[MyStromSwitch], float | None] = lambda _: None + + +SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = ( + MyStromSwitchSensorEntityDescription( + key="consumption", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + value_fn=lambda device: device.consumption, + ), + MyStromSwitchSensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + value_fn=lambda device: device.temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the myStrom entities.""" + device: MyStromSwitch = hass.data[DOMAIN][entry.entry_id].device + sensors = [] + + for description in SENSOR_TYPES: + if description.value_fn(device) is not None: + sensors.append(MyStromSwitchSensor(device, entry.title, description)) + + async_add_entities(sensors) + + +class MyStromSwitchSensor(SensorEntity): + """Representation of the consumption or temperature of a myStrom switch/plug.""" + + entity_description: MyStromSwitchSensorEntityDescription + device: MyStromSwitch + + _attr_has_entity_name = True + + def __init__( + self, + device: MyStromSwitch, + name: str, + description: MyStromSwitchSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + self.device = device + self.entity_description = description + + self._attr_unique_id = f"{device.mac}-{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.mac)}, + name=name, + manufacturer=MANUFACTURER, + sw_version=device.firmware, + ) + + @property + def native_value(self) -> float | None: + """Return the value of the sensor.""" + return self.entity_description.value_fn(self.device) From 3bb23a6d8844898aff956d4b61f269df484241b6 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 18 Oct 2023 17:25:57 +0200 Subject: [PATCH 525/968] Correct process_raw_value for modbus sensor (#102032) --- .../components/modbus/base_platform.py | 57 ++++++------------- 1 file changed, 16 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index ee98b51b72a..edfca94979e 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -194,22 +194,25 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): registers.reverse() return registers - def __process_raw_value( - self, entry: float | int | str | bytes - ) -> float | int | str | bytes | None: + def __process_raw_value(self, entry: float | int | str | bytes) -> str | None: """Process value from sensor with NaN handling, scaling, offset, min/max etc.""" if self._nan_value and entry in (self._nan_value, -self._nan_value): return None if isinstance(entry, bytes): - return entry + return entry.decode() + if entry != entry: # noqa: PLR0124 + # NaN float detection replace with None + return None val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: - return self._min_value + return str(self._min_value) if self._max_value is not None and val > self._max_value: - return self._max_value + return str(self._max_value) if self._zero_suppress is not None and abs(val) <= self._zero_suppress: - return 0 - return val + return "0" + if self._precision == 0: + return str(int(round(val, 0))) + return f"{float(val):.{self._precision}f}" def unpack_structure_result(self, registers: list[int]) -> str | None: """Convert registers to proper result.""" @@ -219,6 +222,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): byte_string = b"".join([x.to_bytes(2, byteorder="big") for x in registers]) if self._data_type == DataType.STRING: return byte_string.decode() + if byte_string == b"nan\x00": + return None try: val = struct.unpack(self._structure, byte_string) @@ -227,49 +232,19 @@ class BaseStructPlatform(BasePlatform, RestoreEntity): msg = f"Received {recv_size} bytes, unpack error {err}" _LOGGER.error(msg) return None - # Issue: https://github.com/home-assistant/core/issues/41944 - # If unpack() returns a tuple greater than 1, don't try to process the value. - # Instead, return the values of unpack(...) separated by commas. if len(val) > 1: # Apply scale, precision, limits to floats and ints v_result = [] for entry in val: v_temp = self.__process_raw_value(entry) - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - if isinstance(v_temp, int) and self._precision == 0: - v_result.append(str(v_temp)) - elif v_temp is None: - v_result.append("0") - elif v_temp != v_temp: # noqa: PLR0124 - # NaN float detection replace with None + if v_temp is None: v_result.append("0") else: - v_result.append(f"{float(v_temp):.{self._precision}f}") + v_result.append(str(v_temp)) return ",".join(map(str, v_result)) - # NaN float detection replace with None - if val[0] != val[0]: # noqa: PLR0124 - return None - if byte_string == b"nan\x00": - return None - # Apply scale, precision, limits to floats and ints - val_result = self.__process_raw_value(val[0]) - - # We could convert int to float, and the code would still work; however - # we lose some precision, and unit tests will fail. Therefore, we do - # the conversion only when it's absolutely necessary. - - if val_result is None: - return None - if isinstance(val_result, int) and self._precision == 0: - return str(val_result) - if isinstance(val_result, bytes): - return val_result.decode() - return f"{float(val_result):.{self._precision}f}" + return self.__process_raw_value(val[0]) class BaseSwitch(BasePlatform, ToggleEntity, RestoreEntity): From 9d775bdbf69d9bb6de459d22686bee46c7f2102a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Oct 2023 17:58:39 +0200 Subject: [PATCH 526/968] Update home-assistant/wheels to 2023.10.5 (#102243) --- .github/workflows/wheels.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index f3bab1872c7..e1dba9c5452 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -99,7 +99,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2023.10.4 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -190,7 +190,7 @@ jobs: sed -i "/numpy/d" homeassistant/package_constraints.txt - name: Build wheels (old cython) - uses: home-assistant/wheels@2023.10.4 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -205,7 +205,7 @@ jobs: pip: "'cython<3'" - name: Build wheels (part 1) - uses: home-assistant/wheels@2023.10.4 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -219,7 +219,7 @@ jobs: requirements: "requirements_all.txtaa" - name: Build wheels (part 2) - uses: home-assistant/wheels@2023.10.4 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 @@ -233,7 +233,7 @@ jobs: requirements: "requirements_all.txtab" - name: Build wheels (part 3) - uses: home-assistant/wheels@2023.10.4 + uses: home-assistant/wheels@2023.10.5 with: abi: ${{ matrix.abi }} tag: musllinux_1_2 From eee294d384ebd488ee249b5a9c3a81ba8363df64 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Wed, 18 Oct 2023 18:07:42 +0200 Subject: [PATCH 527/968] Get diagnostics of all devices in ViCare account (#102218) * get diagnostics of all devices * correct test data * correct test data * correct test data --- homeassistant/components/vicare/__init__.py | 5 +- homeassistant/components/vicare/const.py | 1 + .../components/vicare/diagnostics.py | 9 +- .../vicare/snapshots/test_diagnostics.ambr | 9066 +++++++++-------- 4 files changed, 4543 insertions(+), 4538 deletions(-) diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index 587c98da693..fcfe6497507 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -24,6 +24,7 @@ from .const import ( PLATFORMS, VICARE_API, VICARE_DEVICE_CONFIG, + VICARE_DEVICE_CONFIG_LIST, HeatingType, ) @@ -83,7 +84,9 @@ def setup_vicare_api(hass: HomeAssistant, entry: ConfigEntry) -> None: ) # Currently we only support a single device - device = vicare_api.devices[0] + device_list = vicare_api.devices + device = device_list[0] + hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST] = device_list hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] = device hass.data[DOMAIN][entry.entry_id][VICARE_API] = getattr( device, diff --git a/homeassistant/components/vicare/const.py b/homeassistant/components/vicare/const.py index c3bd3037d96..546f18985e8 100644 --- a/homeassistant/components/vicare/const.py +++ b/homeassistant/components/vicare/const.py @@ -14,6 +14,7 @@ PLATFORMS = [ ] VICARE_DEVICE_CONFIG = "device_conf" +VICARE_DEVICE_CONFIG_LIST = "device_config_list" VICARE_API = "api" VICARE_NAME = "ViCare" diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index b4c0032037c..aa5d08f92d8 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import DOMAIN, VICARE_DEVICE_CONFIG +from .const import DOMAIN, VICARE_DEVICE_CONFIG_LIST TO_REDACT = {CONF_CLIENT_ID, CONF_PASSWORD, CONF_USERNAME} @@ -19,10 +19,9 @@ async def async_get_config_entry_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" # Currently we only support a single device - device = hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG] - data: dict[str, Any] = json.loads( - await hass.async_add_executor_job(device.dump_secure) - ) + data = [] + for device in hass.data[DOMAIN][entry.entry_id][VICARE_DEVICE_CONFIG_LIST]: + data.append(json.loads(await hass.async_add_executor_job(device.dump_secure))) return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "data": data, diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index 1e80bb26fe7..dc1b217948f 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -1,4698 +1,4700 @@ # serializer version: 1 # name: test_diagnostics dict({ - 'data': dict({ - 'data': list([ - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging.level.total', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.707Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.total', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'bottom', - 'middle', - 'top', - 'total', - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging.level', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.solar.pumps.circuit', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.713Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps.circuit', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.burners.0.statistics', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'hours': dict({ - 'type': 'number', - 'unit': '', - 'value': 18726.3, + 'data': list([ + dict({ + 'data': list([ + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'starts': dict({ - 'type': 'number', - 'unit': '', - 'value': 14315, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.total', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:47.707Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.total', }), - 'timestamp': '2021-08-25T14:23:17.238Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.statistics', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes.heating', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.971Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'device', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/device', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.pumps.circulation.schedule', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.694Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.circulation.pump', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'off', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - 'timestamp': '2021-08-25T03:29:47.639Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pump', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.heating.schedule', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.922Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.sensors.temperature.supply', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.572Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.solar.sensors.temperature.collector', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.700Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes.active', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.677Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.burner', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + 'components': list([ + 'bottom', + 'middle', + 'top', + 'total', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level', }), - 'timestamp': '2021-08-25T14:16:46.543Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.operating.programs.holiday', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.714Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs.holiday', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging.level.bottom', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.711Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.bottom', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.sensors.temperature.supply', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'connected', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.pumps.circuit', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 63, + 'timestamp': '2021-08-25T03:29:47.713Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps.circuit', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - 'timestamp': '2021-08-25T15:13:19.679Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes.dhw', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.955Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setMode': dict({ - 'isExecutable': True, - 'name': 'setMode', - 'params': dict({ - 'mode': dict({ - 'constraints': dict({ - 'enum': list([ - 'standby', - 'dhw', - 'dhwAndHeating', - 'forcedReduced', - 'forcedNormal', - ]), - }), - 'required': True, - 'type': 'string', - }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0.statistics', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'hours': dict({ + 'type': 'number', + 'unit': '', + 'value': 18726.3, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes.active', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': 'dhw', - }), - }), - 'timestamp': '2021-08-25T03:29:47.654Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'activate': dict({ - 'isExecutable': True, - 'name': 'activate', - 'params': dict({ - 'temperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 4, - 'stepping': 1, - }), - 'required': False, - 'type': 'number', - }), + 'starts': dict({ + 'type': 'number', + 'unit': '', + 'value': 14315, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate', }), - 'deactivate': dict({ - 'isExecutable': False, - 'name': 'deactivate', - 'params': dict({ + 'timestamp': '2021-08-25T14:23:17.238Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.statistics', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.971Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'device', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/device', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.694Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.circulation.pump', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate', }), - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 4, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), + 'timestamp': '2021-08-25T03:29:47.639Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.922Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.572Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.supply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature.collector', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.700Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.collector', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.677Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burner', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature', }), + 'timestamp': '2021-08-25T14:16:46.543Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burner', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.comfort', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 22, + 'timestamp': '2021-08-25T03:29:47.714Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.bottom', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.711Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.bottom', }), - 'timestamp': '2021-08-25T03:29:46.825Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'operating', - ]), - 'deviceId': '0', - 'feature': 'ventilation', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.717Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setCurve': dict({ - 'isExecutable': True, - 'name': 'setCurve', - 'params': dict({ - 'shift': dict({ - 'constraints': dict({ - 'max': 40, - 'min': -13, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), - 'slope': dict({ - 'constraints': dict({ - 'max': 3.5, - 'min': 0.2, - 'stepping': 0.1, - }), - 'required': True, - 'type': 'number', - }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.heating.curve', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'shift': dict({ - 'type': 'number', - 'unit': '', - 'value': 7, - }), - 'slope': dict({ - 'type': 'number', - 'unit': '', - 'value': 1.1, - }), - }), - 'timestamp': '2021-08-25T03:29:46.909Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.boiler.sensors.temperature.commonSupply', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.838Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pump', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.frostprotection', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.903Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.frostprotection', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'circulation', - 'dhw', - 'frostprotection', - 'heating', - 'operating', - 'sensors', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.863Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pumps', - 'sensors', - ]), - 'deviceId': '0', - 'feature': 'heating.solar', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.698Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'modes', - 'programs', - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'modulation', - 'statistics', - ]), - 'deviceId': '0', - 'feature': 'heating.burners.0', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - }), - 'timestamp': '2021-08-25T14:16:46.550Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'modes', - 'programs', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.standby', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.560Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'changeEndDate': dict({ - 'isExecutable': False, - 'name': 'changeEndDate', - 'params': dict({ - 'end': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - 'sameDayAllowed': False, - }), - 'required': True, - 'type': 'string', - }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/changeEndDate', - }), - 'schedule': dict({ - 'isExecutable': True, - 'name': 'schedule', - 'params': dict({ - 'end': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - 'sameDayAllowed': False, - }), - 'required': True, - 'type': 'string', - }), - 'start': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - }), - 'required': True, - 'type': 'string', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/schedule', - }), - 'unschedule': dict({ - 'isExecutable': True, - 'name': 'unschedule', - 'params': dict({ - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/unschedule', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.holiday', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'end': dict({ - 'type': 'string', - 'value': '', - }), - 'start': dict({ - 'type': 'string', - 'value': '', - }), - }), - 'timestamp': '2021-08-25T03:29:47.541Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.modes.standby', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.726Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'dhw', - 'dhwAndHeating', - 'heating', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.pumps.primary', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'off', - }), - }), - 'timestamp': '2021-08-25T14:18:44.841Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.primary', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.programs.holiday', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.722Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.holiday', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'reduced', - 'maxEntries': 4, - 'modes': list([ - 'normal', - ]), - 'overlapAllowed': True, - 'resolution': 10, - }), - 'required': True, - 'type': 'Schedule', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.heating.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'entries': dict({ - 'type': 'Schedule', 'value': dict({ - 'fri': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'mon': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'sat': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'sun': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'thu': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'tue': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'wed': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), + 'type': 'number', + 'unit': 'celsius', + 'value': 63, }), }), + 'timestamp': '2021-08-25T15:13:19.679Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.supply', }), - 'timestamp': '2021-08-25T03:29:46.920Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.955Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhw', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes.dhwAndHeating', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.967Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 3, - 'stepping': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setMode': dict({ + 'isExecutable': True, + 'name': 'setMode', + 'params': dict({ + 'mode': dict({ + 'constraints': dict({ + 'enum': list([ + 'standby', + 'dhw', + 'dhwAndHeating', + 'forcedReduced', + 'forcedNormal', + ]), + }), + 'required': True, + 'type': 'string', }), - 'required': True, - 'type': 'number', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active/commands/setMode', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature', }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.reduced', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 18, - }), - }), - 'timestamp': '2021-08-25T03:29:47.553Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'offset', - ]), - 'deviceId': '0', - 'feature': 'heating.device.time', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'curve', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.heating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'changeEndDate': dict({ - 'isExecutable': False, - 'name': 'changeEndDate', - 'params': dict({ - 'end': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - 'sameDayAllowed': False, - }), - 'required': True, - 'type': 'string', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/changeEndDate', - }), - 'schedule': dict({ - 'isExecutable': True, - 'name': 'schedule', - 'params': dict({ - 'end': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - 'sameDayAllowed': False, - }), - 'required': True, - 'type': 'string', - }), - 'start': dict({ - 'constraints': dict({ - 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', - }), - 'required': True, - 'type': 'string', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/schedule', - }), - 'unschedule': dict({ - 'isExecutable': True, - 'name': 'unschedule', - 'params': dict({ - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/unschedule', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.holiday', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'end': dict({ - 'type': 'string', - 'value': '', - }), - 'start': dict({ - 'type': 'string', - 'value': '', - }), - }), - 'timestamp': '2021-08-25T03:29:47.543Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setMode': dict({ - 'isExecutable': True, - 'name': 'setMode', - 'params': dict({ - 'mode': dict({ - 'constraints': dict({ - 'enum': list([ - 'standby', - 'dhw', - 'dhwAndHeating', - 'forcedReduced', - 'forcedNormal', - ]), - }), - 'required': True, - 'type': 'string', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes.active', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': 'dhw', - }), - }), - 'timestamp': '2021-08-25T03:29:47.666Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'reduced', - 'maxEntries': 4, - 'modes': list([ - 'normal', - ]), - 'overlapAllowed': True, - 'resolution': 10, - }), - 'required': True, - 'type': 'Schedule', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.heating.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ 'value': dict({ - 'fri': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'mon': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'sat': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'sun': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'thu': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'tue': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), - 'wed': list([ - dict({ - 'end': '22:00', - 'mode': 'normal', - 'position': 0, - 'start': '06:00', - }), - ]), + 'type': 'string', + 'value': 'dhw', }), }), + 'timestamp': '2021-08-25T03:29:47.654Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.active', }), - 'timestamp': '2021-08-25T03:29:46.918Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.controller.serial', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': '################', - }), - }), - 'timestamp': '2021-08-25T03:29:47.574Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.controller.serial', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.external', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, - }), - }), - 'timestamp': '2021-08-25T03:29:47.536Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.external', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setName': dict({ - 'isExecutable': True, - 'name': 'setName', - 'params': dict({ - 'name': dict({ - 'constraints': dict({ - 'maxLength': 20, - 'minLength': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': False, + 'type': 'number', }), - 'required': True, - 'type': 'string', + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/deactivate', + }), + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 22, + }), + }), + 'timestamp': '2021-08-25T03:29:46.825Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'operating', + ]), + 'deviceId': '0', + 'feature': 'ventilation', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.717Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 7, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.1, + }), + }), + 'timestamp': '2021-08-25T03:29:46.909Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors.temperature.commonSupply', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.838Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.commonSupply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.frostprotection', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.903Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.frostprotection', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.863Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.solar', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.698Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modulation', + 'statistics', + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T14:16:46.550Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.560Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'changeEndDate': dict({ + 'isExecutable': False, + 'name': 'changeEndDate', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/changeEndDate', + }), + 'schedule': dict({ + 'isExecutable': True, + 'name': 'schedule', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + 'start': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/schedule', + }), + 'unschedule': dict({ + 'isExecutable': True, + 'name': 'unschedule', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday/commands/unschedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'end': dict({ + 'type': 'string', + 'value': '', + }), + 'start': dict({ + 'type': 'string', + 'value': '', + }), + }), + 'timestamp': '2021-08-25T03:29:47.541Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.726Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.primary', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T14:18:44.841Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.primary', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.722Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'reduced', + 'maxEntries': 4, + 'modes': list([ + 'normal', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'mon': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sat': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sun': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'thu': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'tue': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'wed': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName', }), + 'timestamp': '2021-08-25T03:29:46.920Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating.schedule', }), - 'components': list([ - 'circulation', - 'dhw', - 'frostprotection', - 'heating', - 'operating', - 'sensors', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'name': dict({ - 'type': 'string', - 'value': '', - }), - 'type': dict({ - 'type': 'string', - 'value': 'heatingCircuit', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:46.967Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.dhwAndHeating', }), - 'timestamp': '2021-08-25T03:29:46.859Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - }), - 'timestamp': '2021-08-25T03:29:46.939Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.dhw.pumps.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'comfort', - 'eco', - 'external', - 'holiday', - 'normal', - 'reduced', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'room', - 'supply', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.sensors.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.frostprotection', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'off', - }), - }), - 'timestamp': '2021-08-25T03:29:46.894Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.frostprotection', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes.dhwAndHeating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - }), - 'timestamp': '2021-08-25T03:29:46.958Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'programs', - ]), - 'deviceId': '0', - 'feature': 'heating.operating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'boiler', - 'buffer', - 'burner', - 'burners', - 'circuits', - 'configuration', - 'device', - 'dhw', - 'operating', - 'sensors', - 'solar', - ]), - 'deviceId': '0', - 'feature': 'heating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - '0', - ]), - 'deviceId': '0', - 'feature': 'heating.burners', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.dhw.pumps.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'circuit', - ]), - 'deviceId': '0', - 'feature': 'heating.solar.pumps', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging.level.top', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.708Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.top', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.solar.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'sensors', - 'serial', - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.boiler', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.holiday', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.545Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.holiday', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.sensors.temperature.outside', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'connected', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', - }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 20.8, - }), - }), - 'timestamp': '2021-08-25T15:07:33.251Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature.outside', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.sensors.temperature.room', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.566Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'modes', - 'programs', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.power.consumption.total', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'day': dict({ - 'type': 'array', - 'value': list([ - 0.219, - 0.316, - 0.32, - 0.325, - 0.311, - 0.317, - 0.312, - 0.313, - ]), - }), - 'dayValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T15:10:12.179Z', - }), - 'month': dict({ - 'type': 'array', - 'value': list([ - 7.843, - 9.661, - 9.472, - 31.747, - 35.805, - 37.785, - 35.183, - 39.583, - 37.998, - 31.939, - 30.552, - 13.375, - 9.734, - ]), - }), - 'monthValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T13:22:54.009Z', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'kilowattHour', - }), - 'week': dict({ - 'type': 'array', - 'value': list([ - 0.829, - 2.241, - 2.22, - 2.233, - 2.23, - 2.23, - 2.227, - 2.008, - 2.198, - 2.236, - 2.159, - 2.255, - 2.497, - 6.849, - 7.213, - 6.749, - 7.994, - 7.958, - 8.397, - 8.728, - 8.743, - 7.453, - 8.386, - 8.839, - 8.763, - 8.678, - 7.896, - 8.783, - 9.821, - 8.683, - 9, - 8.738, - 9.027, - 8.974, - 8.882, - 8.286, - 8.448, - 8.785, - 8.704, - 8.053, - 7.304, - 7.078, - 7.251, - 6.839, - 6.902, - 7.042, - 6.864, - 6.818, - 3.938, - 2.308, - 2.283, - 2.246, - 2.269, - ]), - }), - 'weekValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T13:22:51.623Z', - }), - 'year': dict({ - 'type': 'array', - 'value': list([ - 207.106, - 311.579, - 320.275, - ]), - }), - 'yearValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T15:13:33.507Z', - }), - }), - 'timestamp': '2021-08-25T15:13:35.950Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption.total', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pumps', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.modes.active', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.724Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setName': dict({ - 'isExecutable': True, - 'name': 'setName', - 'params': dict({ - 'name': dict({ - 'constraints': dict({ - 'maxLength': 20, - 'minLength': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', }), - 'required': True, - 'type': 'string', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced/commands/setTemperature', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1/commands/setName', }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 18, + }), + }), + 'timestamp': '2021-08-25T03:29:47.553Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.reduced', }), - 'components': list([ - 'circulation', - 'dhw', - 'frostprotection', - 'heating', - 'operating', - 'sensors', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'name': dict({ - 'type': 'string', - 'value': '', - }), - 'type': dict({ - 'type': 'string', - 'value': 'heatingCircuit', + 'components': list([ + 'offset', + ]), + 'deviceId': '0', + 'feature': 'heating.device.time', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time', }), - 'timestamp': '2021-08-25T03:29:46.861Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.gas.consumption.heating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'day': dict({ - 'type': 'array', - 'value': list([ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - ]), - }), - 'dayValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:37.198Z', - }), - 'month': dict({ - 'type': 'array', - 'value': list([ - 0, - 0, - 0, - 3508, - 5710, - 6491, - 7106, - 8131, - 6728, - 3438, - 2113, - 336, - 0, - ]), - }), - 'monthValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:42.956Z', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'kilowattHour', - }), - 'week': dict({ - 'type': 'array', - 'value': list([ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 24, - 544, - 806, - 636, - 1153, - 1081, - 1275, - 1582, - 1594, - 888, - 1353, - 1678, - 1588, - 1507, - 1093, - 1687, - 2679, - 1647, - 1916, - 1668, - 1870, - 1877, - 1785, - 1325, - 1351, - 1718, - 1597, - 1220, - 706, - 562, - 653, - 429, - 442, - 629, - 435, - 414, - 149, - 0, - 0, - 0, - 0, - ]), - }), - 'weekValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-23T01:22:41.933Z', - }), - 'year': dict({ - 'type': 'array', - 'value': list([ - 30946, - 32288, - 37266, - ]), - }), - 'yearValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:38.203Z', - }), - }), - 'timestamp': '2021-08-25T03:29:47.627Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.reduced', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.556Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'off', - 'maxEntries': 4, - 'modes': list([ - 'on', - ]), - 'overlapAllowed': True, - 'resolution': 10, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'changeEndDate': dict({ + 'isExecutable': False, + 'name': 'changeEndDate', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', }), - 'required': True, - 'type': 'Schedule', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/changeEndDate', + }), + 'schedule': dict({ + 'isExecutable': True, + 'name': 'schedule', + 'params': dict({ + 'end': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + 'sameDayAllowed': False, + }), + 'required': True, + 'type': 'string', + }), + 'start': dict({ + 'constraints': dict({ + 'regEx': '^[\\d]{4}-[\\d]{2}-[\\d]{2}$', + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/schedule', + }), + 'unschedule': dict({ + 'isExecutable': True, + 'name': 'unschedule', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday/commands/unschedule', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule/commands/setSchedule', }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'end': dict({ + 'type': 'string', + 'value': '', + }), + 'start': dict({ + 'type': 'string', + 'value': '', + }), + }), + 'timestamp': '2021-08-25T03:29:47.543Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.holiday', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.dhw.pumps.circulation.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setMode': dict({ + 'isExecutable': True, + 'name': 'setMode', + 'params': dict({ + 'mode': dict({ + 'constraints': dict({ + 'enum': list([ + 'standby', + 'dhw', + 'dhwAndHeating', + 'forcedReduced', + 'forcedNormal', + ]), + }), + 'required': True, + 'type': 'string', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active/commands/setMode', + }), }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ 'value': dict({ - 'fri': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'mon': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'sat': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'sun': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'thu': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'tue': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'wed': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), + 'type': 'string', + 'value': 'dhw', }), }), + 'timestamp': '2021-08-25T03:29:47.666Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.active', }), - 'timestamp': '2021-08-25T03:29:46.866Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.programs.standard', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.719Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.standard', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw.pumps.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'off', - 'maxEntries': 4, - 'modes': list([ - 'on', - ]), - 'overlapAllowed': True, - 'resolution': 10, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'reduced', + 'maxEntries': 4, + 'modes': list([ + 'normal', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', }), - 'required': True, - 'type': 'Schedule', + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'mon': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sat': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'sun': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'thu': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'tue': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), + 'wed': list([ + dict({ + 'end': '22:00', + 'mode': 'normal', + 'position': 0, + 'start': '06:00', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule/commands/setSchedule', }), + 'timestamp': '2021-08-25T03:29:46.918Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.schedule', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.controller.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ 'value': dict({ - 'fri': list([ - dict({ - 'end': '10:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 1, - 'start': '16:30', - }), - ]), - 'mon': list([ - dict({ - 'end': '10:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 1, - 'start': '16:30', - }), - ]), - 'sat': list([ - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 0, - 'start': '06:30', - }), - ]), - 'sun': list([ - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 0, - 'start': '06:30', - }), - ]), - 'thu': list([ - dict({ - 'end': '10:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 1, - 'start': '16:30', - }), - ]), - 'tue': list([ - dict({ - 'end': '10:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 1, - 'start': '16:30', - }), - ]), - 'wed': list([ - dict({ - 'end': '10:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - dict({ - 'end': '24:00', - 'mode': 'on', - 'position': 1, - 'start': '16:30', - }), - ]), + 'type': 'string', + 'value': '################', }), }), + 'timestamp': '2021-08-25T03:29:47.574Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.controller.serial', }), - 'timestamp': '2021-08-25T03:29:46.883Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'circulation', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.dhw.pumps', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.external', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.540Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.external', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'multiFamilyHouse', - ]), - 'deviceId': '0', - 'feature': 'heating.configuration', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pumps', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.programs.eco', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.720Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.eco', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.boiler.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 5, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), }), + 'timestamp': '2021-08-25T03:29:47.536Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.external', }), - 'timestamp': '2021-08-25T14:16:46.376Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.boiler.serial', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': '################', - }), - }), - 'timestamp': '2021-08-25T03:29:46.840Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.serial', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'curve', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.heating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.pumps.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'on', - }), - }), - 'timestamp': '2021-08-25T03:29:47.609Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.configuration.multiFamilyHouse', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - }), - 'timestamp': '2021-08-25T03:29:47.693Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration.multiFamilyHouse', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'comfort', - 'eco', - 'external', - 'holiday', - 'normal', - 'reduced', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'modes', - 'programs', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes.standby', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.533Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.standby', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - }), - 'timestamp': '2021-08-25T03:29:47.558Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.modes.ventilation', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.729Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'curve', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.heating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.dhw.pumps.circulation.schedule', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.876Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 3, - 'stepping': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setName': dict({ + 'isExecutable': True, + 'name': 'setName', + 'params': dict({ + 'name': dict({ + 'constraints': dict({ + 'maxLength': 20, + 'minLength': 1, + }), + 'required': True, + 'type': 'string', }), - 'required': True, - 'type': 'number', }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0/commands/setName', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal/commands/setTemperature', }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.normal', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 23, - }), - }), - 'timestamp': '2021-08-25T03:29:47.548Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 3, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.normal', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 21, - }), - }), - 'timestamp': '2021-08-25T03:29:47.546Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes.dhwAndHeating', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - }), - 'timestamp': '2021-08-25T03:29:46.963Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.active', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.649Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - }), - 'timestamp': '2021-08-25T03:29:46.933Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.dhw.schedule', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.890Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'activate': dict({ - 'isExecutable': True, - 'name': 'activate', - 'params': dict({ - 'temperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 4, - 'stepping': 1, - }), - 'required': False, - 'type': 'number', - }), + 'name': dict({ + 'type': 'string', + 'value': '', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/activate', - }), - 'deactivate': dict({ - 'isExecutable': False, - 'name': 'deactivate', - 'params': dict({ + 'type': dict({ + 'type': 'string', + 'value': 'heatingCircuit', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/deactivate', }), - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 4, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), + 'timestamp': '2021-08-25T03:29:46.859Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/setTemperature', }), + 'timestamp': '2021-08-25T03:29:46.939Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhw', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.comfort', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 24, + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation', }), - 'timestamp': '2021-08-25T03:29:46.827Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.standby', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs', }), - 'timestamp': '2021-08-25T03:29:47.559Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setCurve': dict({ - 'isExecutable': True, - 'name': 'setCurve', - 'params': dict({ - 'shift': dict({ - 'constraints': dict({ - 'max': 40, - 'min': -13, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), - 'slope': dict({ - 'constraints': dict({ - 'max': 3.5, - 'min': 0.2, - 'stepping': 0.1, - }), - 'required': True, - 'type': 'number', - }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.frostprotection', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve', }), + 'timestamp': '2021-08-25T03:29:46.894Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.frostprotection', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.heating.curve', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'shift': dict({ - 'type': 'number', - 'unit': '', - 'value': 9, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'slope': dict({ - 'type': 'number', - 'unit': '', - 'value': 1.4, - }), - }), - 'timestamp': '2021-08-25T03:29:46.906Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.eco', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.552Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.gas.consumption.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'day': dict({ - 'type': 'array', - 'value': list([ - 22, - 33, - 32, - 34, - 32, - 32, - 32, - 32, - ]), - }), - 'dayValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T14:16:40.084Z', - }), - 'month': dict({ - 'type': 'array', - 'value': list([ - 805, - 1000, - 968, - 1115, - 1109, - 1087, - 995, - 1124, - 1087, - 1094, - 1136, - 1009, - 966, - ]), - }), - 'monthValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T13:22:47.985Z', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'kilowattHour', - }), - 'week': dict({ - 'type': 'array', - 'value': list([ - 84, - 232, - 226, - 230, - 230, - 226, - 229, - 214, - 229, - 229, - 220, - 229, - 229, - 250, - 244, - 247, - 266, - 268, - 268, - 255, - 248, - 247, - 242, - 244, - 248, - 250, - 238, - 242, - 259, - 256, - 259, - 263, - 255, - 241, - 257, - 250, - 237, - 240, - 243, - 253, - 257, - 253, - 258, - 261, - 254, - 254, - 256, - 258, - 240, - 240, - 230, - 223, - 231, - ]), - }), - 'weekValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T13:22:47.418Z', - }), - 'year': dict({ - 'type': 'array', - 'value': list([ - 8203, - 12546, - 11741, - ]), - }), - 'yearValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-25T13:22:51.902Z', - }), - }), - 'timestamp': '2021-08-25T14:16:41.758Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - '0', - '1', - '2', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'enabled': dict({ - 'type': 'array', - 'value': list([ - '0', - '1', - ]), - }), - }), - 'timestamp': '2021-08-25T03:29:46.864Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.active', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': 'standby', - }), - }), - 'timestamp': '2021-08-25T03:29:47.643Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.solar.power.production', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.634Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.power.production', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'activate': dict({ - 'isExecutable': False, - 'name': 'activate', - 'params': dict({ + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate', }), - 'deactivate': dict({ - 'isExecutable': False, - 'name': 'deactivate', - 'params': dict({ + 'timestamp': '2021-08-25T03:29:46.958Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhwAndHeating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'boiler', + 'buffer', + 'burner', + 'burners', + 'circuits', + 'configuration', + 'device', + 'dhw', + 'operating', + 'sensors', + 'solar', + ]), + 'deviceId': '0', + 'feature': 'heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + '0', + ]), + 'deviceId': '0', + 'feature': 'heating.burners', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circuit', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.top', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.708Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.top', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'sensors', + 'serial', + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.boiler', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.holiday', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.545Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.holiday', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.sensors.temperature.outside', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.programs.eco', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 21, - }), - }), - 'timestamp': '2021-08-25T03:29:47.547Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.normal', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.551Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'charging', - 'oneTimeCharge', - 'schedule', - 'sensors', - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - 'status': dict({ - 'type': 'string', - 'value': 'on', - }), - }), - 'timestamp': '2021-08-25T03:29:47.650Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.circulation.pump', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.642Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.boiler.sensors.temperature.main', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'connected', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', - }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 63, - }), - }), - 'timestamp': '2021-08-25T15:13:19.598Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.main', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.circulation.pump', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'off', - }), - }), - 'timestamp': '2021-08-25T03:29:47.641Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'activate': dict({ - 'isExecutable': False, - 'name': 'activate', - 'params': dict({ + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/activate', - }), - 'deactivate': dict({ - 'isExecutable': False, - 'name': 'deactivate', - 'params': dict({ - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/deactivate', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.eco', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 23, - }), - }), - 'timestamp': '2021-08-25T03:29:47.549Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.charging.level', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'bottom': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, - }), - 'middle': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, - }), - 'top': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, - }), - 'value': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, - }), - }), - 'timestamp': '2021-08-25T03:29:47.603Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging.level', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pump', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.circulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.modes.standard', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.728Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standard', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'holiday', - ]), - 'deviceId': '0', - 'feature': 'heating.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'off', - 'maxEntries': 4, - 'modes': list([ - 'on', - ]), - 'overlapAllowed': True, - 'resolution': 10, - }), - 'required': True, - 'type': 'Schedule', - }), - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule/commands/setSchedule', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.dhw.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, - }), - 'entries': dict({ - 'type': 'Schedule', 'value': dict({ - 'fri': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'mon': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'sat': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'sun': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'thu': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'tue': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'wed': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), + 'type': 'number', + 'unit': 'celsius', + 'value': 20.8, }), }), + 'timestamp': '2021-08-25T15:07:33.251Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature.outside', }), - 'timestamp': '2021-08-25T03:29:46.880Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.566Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature.room', }), - 'components': list([ - 'eco', - 'holiday', - 'standard', - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating', }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setSchedule': dict({ - 'isExecutable': True, - 'name': 'setSchedule', - 'params': dict({ - 'newSchedule': dict({ - 'constraints': dict({ - 'defaultMode': 'off', - 'maxEntries': 4, - 'modes': list([ - 'on', - ]), - 'overlapAllowed': True, - 'resolution': 10, + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.power.consumption.total', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 0.219, + 0.316, + 0.32, + 0.325, + 0.311, + 0.317, + 0.312, + 0.313, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T15:10:12.179Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 7.843, + 9.661, + 9.472, + 31.747, + 35.805, + 37.785, + 35.183, + 39.583, + 37.998, + 31.939, + 30.552, + 13.375, + 9.734, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:54.009Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 0.829, + 2.241, + 2.22, + 2.233, + 2.23, + 2.23, + 2.227, + 2.008, + 2.198, + 2.236, + 2.159, + 2.255, + 2.497, + 6.849, + 7.213, + 6.749, + 7.994, + 7.958, + 8.397, + 8.728, + 8.743, + 7.453, + 8.386, + 8.839, + 8.763, + 8.678, + 7.896, + 8.783, + 9.821, + 8.683, + 9, + 8.738, + 9.027, + 8.974, + 8.882, + 8.286, + 8.448, + 8.785, + 8.704, + 8.053, + 7.304, + 7.078, + 7.251, + 6.839, + 6.902, + 7.042, + 6.864, + 6.818, + 3.938, + 2.308, + 2.283, + 2.246, + 2.269, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:51.623Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 207.106, + 311.579, + 320.275, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T15:13:33.507Z', + }), + }), + 'timestamp': '2021-08-25T15:13:35.950Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption.total', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.724Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setName': dict({ + 'isExecutable': True, + 'name': 'setName', + 'params': dict({ + 'name': dict({ + 'constraints': dict({ + 'maxLength': 20, + 'minLength': 1, + }), + 'required': True, + 'type': 'string', }), - 'required': True, - 'type': 'Schedule', + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1/commands/setName', + }), + }), + 'components': list([ + 'circulation', + 'dhw', + 'frostprotection', + 'heating', + 'operating', + 'sensors', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'name': dict({ + 'type': 'string', + 'value': '', + }), + 'type': dict({ + 'type': 'string', + 'value': 'heatingCircuit', + }), + }), + 'timestamp': '2021-08-25T03:29:46.861Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:37.198Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 3508, + 5710, + 6491, + 7106, + 8131, + 6728, + 3438, + 2113, + 336, + 0, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:42.956Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 24, + 544, + 806, + 636, + 1153, + 1081, + 1275, + 1582, + 1594, + 888, + 1353, + 1678, + 1588, + 1507, + 1093, + 1687, + 2679, + 1647, + 1916, + 1668, + 1870, + 1877, + 1785, + 1325, + 1351, + 1718, + 1597, + 1220, + 706, + 562, + 653, + 429, + 442, + 629, + 435, + 414, + 149, + 0, + 0, + 0, + 0, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-23T01:22:41.933Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 30946, + 32288, + 37266, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:38.203Z', + }), + }), + 'timestamp': '2021-08-25T03:29:47.627Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.556Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.reduced', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule/commands/setSchedule', }), + 'timestamp': '2021-08-25T03:29:46.866Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps.circulation.schedule', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw.pumps.circulation.schedule', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': True, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'entries': dict({ - 'type': 'Schedule', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.standard', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.719Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.standard', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '10:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + dict({ + 'end': '24:00', + 'mode': 'on', + 'position': 1, + 'start': '16:30', + }), + ]), + }), + }), + }), + 'timestamp': '2021-08-25T03:29:46.883Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.540Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.external', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'multiFamilyHouse', + ]), + 'deviceId': '0', + 'feature': 'heating.configuration', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.720Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), 'value': dict({ - 'fri': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', + 'type': 'number', + 'unit': 'celsius', + 'value': 5, + }), + }), + 'timestamp': '2021-08-25T14:16:46.376Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.serial', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': '################', + }), + }), + 'timestamp': '2021-08-25T03:29:46.840Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.serial', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.pumps.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'on', + }), + }), + 'timestamp': '2021-08-25T03:29:47.609Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.pumps.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.configuration.multiFamilyHouse', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.693Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.configuration.multiFamilyHouse', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'modes', + 'programs', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.533Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.558Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.ventilation', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.729Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.ventilation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'curve', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.876Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.pumps.circulation.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 23, + }), + }), + 'timestamp': '2021-08-25T03:29:47.548Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.546Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.dhwAndHeating', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:46.963Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.dhwAndHeating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.649Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:46.933Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.890Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': False, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/deactivate', + }), + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 4, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 24, + }), + }), + 'timestamp': '2021-08-25T03:29:46.827Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + }), + 'timestamp': '2021-08-25T03:29:47.559Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 9, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.4, + }), + }), + 'timestamp': '2021-08-25T03:29:46.906Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.552Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 22, + 33, + 32, + 34, + 32, + 32, + 32, + 32, ]), - 'mon': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '05:30', - }), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T14:16:40.084Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 805, + 1000, + 968, + 1115, + 1109, + 1087, + 995, + 1124, + 1087, + 1094, + 1136, + 1009, + 966, ]), - 'sat': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '05:30', - }), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:47.985Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 84, + 232, + 226, + 230, + 230, + 226, + 229, + 214, + 229, + 229, + 220, + 229, + 229, + 250, + 244, + 247, + 266, + 268, + 268, + 255, + 248, + 247, + 242, + 244, + 248, + 250, + 238, + 242, + 259, + 256, + 259, + 263, + 255, + 241, + 257, + 250, + 237, + 240, + 243, + 253, + 257, + 253, + 258, + 261, + 254, + 254, + 256, + 258, + 240, + 240, + 230, + 223, + 231, ]), - 'sun': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '06:30', - }), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:47.418Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 8203, + 12546, + 11741, ]), - 'thu': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'tue': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), - ]), - 'wed': list([ - dict({ - 'end': '20:00', - 'mode': 'on', - 'position': 0, - 'start': '04:30', - }), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-25T13:22:51.902Z', + }), + }), + 'timestamp': '2021-08-25T14:16:41.758Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + '0', + '1', + '2', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'enabled': dict({ + 'type': 'array', + 'value': list([ + '0', + '1', ]), }), }), + 'timestamp': '2021-08-25T03:29:46.864Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits', }), - 'timestamp': '2021-08-25T03:29:46.871Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'room', - 'supply', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.sensors.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging.level.middle', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.710Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.middle', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes.standby', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'standby', + }), + }), + 'timestamp': '2021-08-25T03:29:47.643Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.active', }), - 'timestamp': '2021-08-25T03:29:47.508Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setTargetTemperature': dict({ - 'isExecutable': True, - 'name': 'setTargetTemperature', - 'params': dict({ - 'temperature': dict({ - 'constraints': dict({ - 'efficientLowerBorder': 10, - 'efficientUpperBorder': 60, - 'max': 60, - 'min': 10, - 'stepping': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.power.production', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.634Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.power.production', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': False, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco/commands/deactivate', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.547Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.normal', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.551Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.normal', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'charging', + 'oneTimeCharge', + 'schedule', + 'sensors', + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'status': dict({ + 'type': 'string', + 'value': 'on', + }), + }), + 'timestamp': '2021-08-25T03:29:47.650Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.circulation.pump', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.642Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors.temperature.main', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 63, + }), + }), + 'timestamp': '2021-08-25T15:13:19.598Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors.temperature.main', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.circulation.pump', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:47.641Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation.pump', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': False, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco/commands/deactivate', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.eco', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 23, + }), + }), + 'timestamp': '2021-08-25T03:29:47.549Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.eco', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.charging.level', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'bottom': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'middle': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'top': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T03:29:47.603Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging.level', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pump', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.circulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.circulation', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes.standard', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.728Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes.standard', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'holiday', + ]), + 'deviceId': '0', + 'feature': 'heating.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', }), - 'required': True, - 'type': 'number', + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature', }), + 'timestamp': '2021-08-25T03:29:46.880Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.schedule', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.temperature.main', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'number', - 'unit': '', - 'value': 58, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), + 'components': list([ + 'eco', + 'holiday', + 'standard', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.programs', }), - 'timestamp': '2021-08-25T03:29:46.819Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'activate': dict({ - 'isExecutable': True, - 'name': 'activate', - 'params': dict({ - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate', - }), - 'deactivate': dict({ - 'isExecutable': False, - 'name': 'deactivate', - 'params': dict({ - }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate', - }), - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.oneTimeCharge', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, - }), - }), - 'timestamp': '2021-08-25T03:29:47.607Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.gas.consumption.total', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'day': dict({ - 'type': 'array', - 'value': list([ - 22, - 33, - 32, - 34, - 32, - 32, - 32, - 32, - ]), - }), - 'dayValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:37.198Z', - }), - 'month': dict({ - 'type': 'array', - 'value': list([ - 805, - 1000, - 968, - 4623, - 6819, - 7578, - 8101, - 9255, - 7815, - 4532, - 3249, - 1345, - 966, - ]), - }), - 'monthValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:42.956Z', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'kilowattHour', - }), - 'week': dict({ - 'type': 'array', - 'value': list([ - 84, - 232, - 226, - 230, - 230, - 226, - 229, - 214, - 229, - 229, - 220, - 229, - 253, - 794, - 1050, - 883, - 1419, - 1349, - 1543, - 1837, - 1842, - 1135, - 1595, - 1922, - 1836, - 1757, - 1331, - 1929, - 2938, - 1903, - 2175, - 1931, - 2125, - 2118, - 2042, - 1575, - 1588, - 1958, - 1840, - 1473, - 963, - 815, - 911, - 690, - 696, - 883, - 691, - 672, - 389, - 240, - 230, - 223, - 231, - ]), - }), - 'weekValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-23T01:22:41.933Z', - }), - 'year': dict({ - 'type': 'array', - 'value': list([ - 39149, - 44834, - 49007, - ]), - }), - 'yearValueReadAt': dict({ - 'type': 'string', - 'value': '2021-08-18T21:22:38.203Z', - }), - }), - 'timestamp': '2021-08-25T14:16:41.785Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.total', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.burners.0.modulation', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'unit': dict({ - 'type': 'string', - 'value': 'percent', - }), - 'value': dict({ - 'type': 'number', - 'unit': 'percent', - 'value': 0, - }), - }), - 'timestamp': '2021-08-25T14:16:46.499Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.modulation', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'total', - ]), - 'deviceId': '0', - 'feature': 'heating.power.consumption', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setTemperature': dict({ - 'isExecutable': True, - 'name': 'setTemperature', - 'params': dict({ - 'targetTemperature': dict({ - 'constraints': dict({ - 'max': 37, - 'min': 3, - 'stepping': 1, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setSchedule': dict({ + 'isExecutable': True, + 'name': 'setSchedule', + 'params': dict({ + 'newSchedule': dict({ + 'constraints': dict({ + 'defaultMode': 'off', + 'maxEntries': 4, + 'modes': list([ + 'on', + ]), + 'overlapAllowed': True, + 'resolution': 10, + }), + 'required': True, + 'type': 'Schedule', }), - 'required': True, - 'type': 'number', + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule/commands/setSchedule', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps.circulation.schedule', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': True, + }), + 'entries': dict({ + 'type': 'Schedule', + 'value': dict({ + 'fri': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'mon': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '05:30', + }), + ]), + 'sat': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '05:30', + }), + ]), + 'sun': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '06:30', + }), + ]), + 'thu': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'tue': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), + 'wed': list([ + dict({ + 'end': '20:00', + 'mode': 'on', + 'position': 0, + 'start': '04:30', + }), + ]), }), }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced/commands/setTemperature', }), + 'timestamp': '2021-08-25T03:29:46.871Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps.circulation.schedule', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.reduced', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'demand': dict({ - 'type': 'string', - 'value': 'unknown', + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 21, + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - 'timestamp': '2021-08-25T03:29:47.555Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'outside', - ]), - 'deviceId': '0', - 'feature': 'heating.sensors.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.sensors.temperature.room', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.564Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.boiler.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'collector', - 'dhw', - ]), - 'deviceId': '0', - 'feature': 'heating.solar.sensors.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'level', - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.charging', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging.level.middle', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ }), + 'timestamp': '2021-08-25T03:29:47.710Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging.level.middle', }), - 'timestamp': '2021-08-25T14:16:41.453Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes.standby', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - 'timestamp': '2021-08-25T03:29:47.524Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'charging', - ]), - 'deviceId': '0', - 'feature': 'heating.buffer', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'main', - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.active', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'string', - 'value': 'standby', - }), - }), - 'timestamp': '2021-08-25T03:29:47.645Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.schedule', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.695Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.schedule', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'level', - ]), - 'deviceId': '0', - 'feature': 'heating.buffer.charging', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.programs.comfort', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.830Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'dhw', - 'dhwAndHeating', - 'heating', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.operating.modes', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'standard', - 'standby', - 'ventilation', - ]), - 'deviceId': '0', - 'feature': 'ventilation.operating.modes', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'circulation', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.dhw.pumps', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'active', - 'comfort', - 'eco', - 'external', - 'holiday', - 'normal', - 'reduced', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.operating.modes.heating', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.978Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'room', - 'supply', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.sensors.temperature', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.sensors.temperature.outlet', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'error', - }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', - }), - }), - 'timestamp': '2021-08-25T03:29:47.637Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'time', - ]), - 'deviceId': '0', - 'feature': 'heating.device', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'temperature', - ]), - 'deviceId': '0', - 'feature': 'heating.sensors', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.device.time.offset', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'value': dict({ - 'type': 'number', - 'unit': '', - 'value': 96, - }), - }), - 'timestamp': '2021-08-25T03:29:47.575Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time.offset', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.sensors.temperature.room', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.562Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'circulation', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.dhw.pumps', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.frostprotection', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'off', - }), - }), - 'timestamp': '2021-08-25T03:29:46.900Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.frostprotection', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.solar.sensors.temperature.dhw', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:47.633Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - 'pumps', - 'schedule', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.0.dhw', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.400Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - 'setCurve': dict({ - 'isExecutable': True, - 'name': 'setCurve', - 'params': dict({ - 'shift': dict({ - 'constraints': dict({ - 'max': 40, - 'min': -13, - 'stepping': 1, - }), - 'required': True, - 'type': 'number', - }), - 'slope': dict({ - 'constraints': dict({ - 'max': 3.5, - 'min': 0.2, - 'stepping': 0.1, - }), - 'required': True, - 'type': 'number', - }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, }), - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve/commands/setCurve', }), + 'timestamp': '2021-08-25T03:29:47.508Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes.standby', }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.2.heating.curve', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'shift': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTargetTemperature': dict({ + 'isExecutable': True, + 'name': 'setTargetTemperature', + 'params': dict({ + 'temperature': dict({ + 'constraints': dict({ + 'efficientLowerBorder': 10, + 'efficientUpperBorder': 60, + 'max': 60, + 'min': 10, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main/commands/setTargetTemperature', + }), }), - 'slope': dict({ - 'type': 'number', - 'unit': '', - 'value': 1.4, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.temperature.main', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 58, + }), }), + 'timestamp': '2021-08-25T03:29:46.819Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature.main', }), - 'timestamp': '2021-08-25T03:29:46.910Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes.heating', - 'gatewayId': '################', - 'isEnabled': False, - 'isReady': True, - 'properties': dict({ - }), - 'timestamp': '2021-08-25T03:29:46.975Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.programs.external', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'active': dict({ - 'type': 'boolean', - 'value': False, + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'activate': dict({ + 'isExecutable': True, + 'name': 'activate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/activate', + }), + 'deactivate': dict({ + 'isExecutable': False, + 'name': 'deactivate', + 'params': dict({ + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge/commands/deactivate', + }), }), - 'temperature': dict({ - 'type': 'number', - 'unit': '', - 'value': 0, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.oneTimeCharge', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), }), + 'timestamp': '2021-08-25T03:29:47.607Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.oneTimeCharge', }), - 'timestamp': '2021-08-25T03:29:47.538Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.external', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.dhw.sensors.temperature.hotWaterStorage', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'connected', + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.gas.consumption.total', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'day': dict({ + 'type': 'array', + 'value': list([ + 22, + 33, + 32, + 34, + 32, + 32, + 32, + 32, + ]), + }), + 'dayValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:37.198Z', + }), + 'month': dict({ + 'type': 'array', + 'value': list([ + 805, + 1000, + 968, + 4623, + 6819, + 7578, + 8101, + 9255, + 7815, + 4532, + 3249, + 1345, + 966, + ]), + }), + 'monthValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:42.956Z', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'kilowattHour', + }), + 'week': dict({ + 'type': 'array', + 'value': list([ + 84, + 232, + 226, + 230, + 230, + 226, + 229, + 214, + 229, + 229, + 220, + 229, + 253, + 794, + 1050, + 883, + 1419, + 1349, + 1543, + 1837, + 1842, + 1135, + 1595, + 1922, + 1836, + 1757, + 1331, + 1929, + 2938, + 1903, + 2175, + 1931, + 2125, + 2118, + 2042, + 1575, + 1588, + 1958, + 1840, + 1473, + 963, + 815, + 911, + 690, + 696, + 883, + 691, + 672, + 389, + 240, + 230, + 223, + 231, + ]), + }), + 'weekValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-23T01:22:41.933Z', + }), + 'year': dict({ + 'type': 'array', + 'value': list([ + 39149, + 44834, + 49007, + ]), + }), + 'yearValueReadAt': dict({ + 'type': 'string', + 'value': '2021-08-18T21:22:38.203Z', + }), }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 58.6, + 'timestamp': '2021-08-25T14:16:41.785Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.gas.consumption.total', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - }), - 'timestamp': '2021-08-25T15:02:49.557Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ - }), - 'components': list([ - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.sensors.temperature.supply', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ - 'status': dict({ - 'type': 'string', - 'value': 'connected', + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ }), - 'unit': dict({ - 'type': 'string', - 'value': 'celsius', + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ }), - 'value': dict({ - 'type': 'number', - 'unit': 'celsius', - 'value': 25.5, + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.burners.0.modulation', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'unit': dict({ + 'type': 'string', + 'value': 'percent', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'percent', + 'value': 0, + }), }), + 'timestamp': '2021-08-25T14:16:46.499Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.burners.0.modulation', }), - 'timestamp': '2021-08-25T11:03:00.515Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply', - }), - dict({ - 'apiVersion': 1, - 'commands': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'total', + ]), + 'deviceId': '0', + 'feature': 'heating.power.consumption', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.power.consumption', }), - 'components': list([ - 'active', - 'dhw', - 'dhwAndHeating', - 'heating', - 'standby', - ]), - 'deviceId': '0', - 'feature': 'heating.circuits.1.operating.modes', - 'gatewayId': '################', - 'isEnabled': True, - 'isReady': True, - 'properties': dict({ + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setTemperature': dict({ + 'isExecutable': True, + 'name': 'setTemperature', + 'params': dict({ + 'targetTemperature': dict({ + 'constraints': dict({ + 'max': 37, + 'min': 3, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced/commands/setTemperature', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.reduced', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'demand': dict({ + 'type': 'string', + 'value': 'unknown', + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 21, + }), + }), + 'timestamp': '2021-08-25T03:29:47.555Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.reduced', }), - 'timestamp': '2021-08-25T03:29:46.401Z', - 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes', - }), - ]), - }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'outside', + ]), + 'deviceId': '0', + 'feature': 'heating.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.564Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.room', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.boiler.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.boiler.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'collector', + 'dhw', + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'level', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.charging', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T14:16:41.453Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.charging', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.standby', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + }), + 'timestamp': '2021-08-25T03:29:47.524Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.standby', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'charging', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'main', + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.active', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'string', + 'value': 'standby', + }), + }), + 'timestamp': '2021-08-25T03:29:47.645Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.active', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.schedule', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.695Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.schedule', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'level', + ]), + 'deviceId': '0', + 'feature': 'heating.buffer.charging', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.buffer.charging', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.programs.comfort', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.830Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.programs.comfort', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'standard', + 'standby', + 'ventilation', + ]), + 'deviceId': '0', + 'feature': 'ventilation.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/ventilation.operating.modes', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'comfort', + 'eco', + 'external', + 'holiday', + 'normal', + 'reduced', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.978Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'room', + 'supply', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.sensors.temperature', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.sensors.temperature', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors.temperature.outlet', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'error', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + }), + 'timestamp': '2021-08-25T03:29:47.637Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.outlet', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'time', + ]), + 'deviceId': '0', + 'feature': 'heating.device', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'temperature', + ]), + 'deviceId': '0', + 'feature': 'heating.sensors', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.sensors', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.device.time.offset', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'value': dict({ + 'type': 'number', + 'unit': '', + 'value': 96, + }), + }), + 'timestamp': '2021-08-25T03:29:47.575Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.device.time.offset', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.sensors.temperature.room', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.562Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.sensors.temperature.room', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'circulation', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw.pumps', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw.pumps', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.frostprotection', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'off', + }), + }), + 'timestamp': '2021-08-25T03:29:46.900Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.frostprotection', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.solar.sensors.temperature.dhw', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:47.633Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.solar.sensors.temperature.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'pumps', + 'schedule', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.0.dhw', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.400Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.0.dhw', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + 'setCurve': dict({ + 'isExecutable': True, + 'name': 'setCurve', + 'params': dict({ + 'shift': dict({ + 'constraints': dict({ + 'max': 40, + 'min': -13, + 'stepping': 1, + }), + 'required': True, + 'type': 'number', + }), + 'slope': dict({ + 'constraints': dict({ + 'max': 3.5, + 'min': 0.2, + 'stepping': 0.1, + }), + 'required': True, + 'type': 'number', + }), + }), + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve/commands/setCurve', + }), + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.2.heating.curve', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'shift': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + 'slope': dict({ + 'type': 'number', + 'unit': '', + 'value': 1.4, + }), + }), + 'timestamp': '2021-08-25T03:29:46.910Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.2.heating.curve', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes.heating', + 'gatewayId': '################', + 'isEnabled': False, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.975Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes.heating', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.programs.external', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'active': dict({ + 'type': 'boolean', + 'value': False, + }), + 'temperature': dict({ + 'type': 'number', + 'unit': '', + 'value': 0, + }), + }), + 'timestamp': '2021-08-25T03:29:47.538Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.programs.external', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.dhw.sensors.temperature.hotWaterStorage', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 58.6, + }), + }), + 'timestamp': '2021-08-25T15:02:49.557Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.dhw.sensors.temperature.hotWaterStorage', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.sensors.temperature.supply', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + 'status': dict({ + 'type': 'string', + 'value': 'connected', + }), + 'unit': dict({ + 'type': 'string', + 'value': 'celsius', + }), + 'value': dict({ + 'type': 'number', + 'unit': 'celsius', + 'value': 25.5, + }), + }), + 'timestamp': '2021-08-25T11:03:00.515Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.sensors.temperature.supply', + }), + dict({ + 'apiVersion': 1, + 'commands': dict({ + }), + 'components': list([ + 'active', + 'dhw', + 'dhwAndHeating', + 'heating', + 'standby', + ]), + 'deviceId': '0', + 'feature': 'heating.circuits.1.operating.modes', + 'gatewayId': '################', + 'isEnabled': True, + 'isReady': True, + 'properties': dict({ + }), + 'timestamp': '2021-08-25T03:29:46.401Z', + 'uri': 'https://api.viessmann-platform.io/iot/v1/equipment/installations/######/gateways/################/devices/0/features/heating.circuits.1.operating.modes', + }), + ]), + }), + ]), 'entry': dict({ 'data': dict({ 'client_id': '**REDACTED**', From 1b73219137cc120525ba566ff8b9ee033e03f235 Mon Sep 17 00:00:00 2001 From: dupondje Date: Wed, 18 Oct 2023 18:13:09 +0200 Subject: [PATCH 528/968] Bump dsmr_parser to 1.3.0 (#102225) bump dsmr_parser==1.3.0 --- homeassistant/components/dsmr/const.py | 3 + homeassistant/components/dsmr/manifest.json | 2 +- homeassistant/components/dsmr/sensor.py | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dsmr/conftest.py | 34 ++++++++--- tests/components/dsmr/test_sensor.py | 67 ++++++++++++++------- 7 files changed, 77 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 5e1a54aedc4..7bc0247aea6 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -34,3 +34,6 @@ DSMR_VERSIONS = {"2.2", "4", "5", "5B", "5L", "5S", "Q3D"} DSMR_PROTOCOL = "dsmr_protocol" RFXTRX_DSMR_PROTOCOL = "rfxtrx_dsmr_protocol" + +# Temp obis until sensors replaced by mbus variants +BELGIUM_5MIN_GAS_METER_READING = r"\d-\d:24\.2\.3.+?\r\n" diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json index 3fc81d2f8e7..b3f59a15b80 100644 --- a/homeassistant/components/dsmr/manifest.json +++ b/homeassistant/components/dsmr/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["dsmr_parser"], - "requirements": ["dsmr-parser==0.33"] + "requirements": ["dsmr-parser==1.3.0"] } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 5bd5138a7d6..2ff0a834e9e 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -39,6 +39,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle from .const import ( + BELGIUM_5MIN_GAS_METER_READING, CONF_DSMR_VERSION, CONF_PRECISION, CONF_PROTOCOL, @@ -361,7 +362,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="belgium_5min_gas_meter_reading", translation_key="gas_meter_reading", - obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING, + obis_reference=BELGIUM_5MIN_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, force_update=True, diff --git a/requirements_all.txt b/requirements_all.txt index 8eff0d9e307..c5b17fa6a56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -701,7 +701,7 @@ dovado==0.4.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==0.33 +dsmr-parser==1.3.0 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 314a8b15398..047a0c028b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -572,7 +572,7 @@ discovery30303==0.2.1 dremel3dpy==2.1.1 # homeassistant.components.dsmr -dsmr-parser==0.33 +dsmr-parser==1.3.0 # homeassistant.components.dwd_weather_warnings dwdwfsapi==1.0.6 diff --git a/tests/components/dsmr/conftest.py b/tests/components/dsmr/conftest.py index 8c94c756edc..67e8b724a97 100644 --- a/tests/components/dsmr/conftest.py +++ b/tests/components/dsmr/conftest.py @@ -68,9 +68,15 @@ async def dsmr_connection_send_validate_fixture(hass): protocol = MagicMock(spec=DSMRProtocol) protocol.telegram = { - EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), - EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + EQUIPMENT_IDENTIFIER: CosemObject( + EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } async def connection_factory(*args, **kwargs): @@ -78,20 +84,22 @@ async def dsmr_connection_send_validate_fixture(hass): if args[1] == "5L": protocol.telegram = { LUXEMBOURG_EQUIPMENT_IDENTIFIER: CosemObject( - [{"value": "12345678", "unit": ""}] + LUXEMBOURG_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] ), EQUIPMENT_IDENTIFIER_GAS: CosemObject( - [{"value": "123456789", "unit": ""}] + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] ), } if args[1] == "5S": protocol.telegram = { - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } if args[1] == "Q3D": protocol.telegram = { Q3D_EQUIPMENT_IDENTIFIER: CosemObject( - [{"value": "12345678", "unit": ""}] + Q3D_EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] ), } @@ -129,9 +137,15 @@ async def rfxtrx_dsmr_connection_send_validate_fixture(hass): protocol = MagicMock(spec=RFXtrxDSMRProtocol) protocol.telegram = { - EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]), - EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]), - P1_MESSAGE_TIMESTAMP: CosemObject([{"value": "12345678", "unit": ""}]), + EQUIPMENT_IDENTIFIER: CosemObject( + EQUIPMENT_IDENTIFIER, [{"value": "12345678", "unit": ""}] + ), + EQUIPMENT_IDENTIFIER_GAS: CosemObject( + EQUIPMENT_IDENTIFIER_GAS, [{"value": "123456789", "unit": ""}] + ), + P1_MESSAGE_TIMESTAMP: CosemObject( + P1_MESSAGE_TIMESTAMP, [{"value": "12345678", "unit": ""}] + ), } async def connection_factory(*args, **kwargs): diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index ed04bda02f8..6972f0cc0cf 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -11,6 +11,7 @@ from itertools import chain, repeat from unittest.mock import DEFAULT, MagicMock from homeassistant import config_entries +from homeassistant.components.dsmr.const import BELGIUM_5MIN_GAS_METER_READING from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, @@ -59,14 +60,18 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { CURRENT_ELECTRICITY_USAGE: CosemObject( - [{"value": Decimal("0.0"), "unit": UnitOfPower.WATT}] + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("0.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), GAS_METER_READING: MBusObject( + GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": UnitOfVolume.CUBIC_METERS}, - ] + ], ), } @@ -199,12 +204,15 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -275,12 +283,15 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -348,16 +359,19 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> telegram = { HOURLY_GAS_METER_READING: MBusObject( + HOURLY_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], ), ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -416,10 +430,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ( - BELGIUM_5MIN_GAS_METER_READING, - ELECTRICITY_ACTIVE_TARIFF, - ) + from dsmr_parser.obis_references import ELECTRICITY_ACTIVE_TARIFF from dsmr_parser.objects import CosemObject, MBusObject entry_data = { @@ -436,12 +447,15 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { BELGIUM_5MIN_GAS_METER_READING: MBusObject( + BELGIUM_5MIN_GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(745.695), "unit": "m3"}, - ] + ], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), } mock_entry = MockConfigEntry( @@ -503,7 +517,11 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - "time_between_update": 0, } - telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])} + telegram = { + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0002", "unit": ""}] + ) + } mock_entry = MockConfigEntry( domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options @@ -556,10 +574,12 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No telegram = { ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(123.456), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(654.321), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -629,10 +649,12 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: telegram = { ELECTRICITY_IMPORTED_TOTAL: CosemObject( - [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_IMPORTED_TOTAL, + [{"value": Decimal(54184.6316), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), ELECTRICITY_EXPORTED_TOTAL: CosemObject( - [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}] + ELECTRICITY_EXPORTED_TOTAL, + [{"value": Decimal(19981.1069), "unit": UnitOfEnergy.KILO_WATT_HOUR}], ), } @@ -856,10 +878,11 @@ async def test_gas_meter_providing_energy_reading( telegram = { GAS_METER_READING: MBusObject( + GAS_METER_READING, [ {"value": datetime.datetime.fromtimestamp(1551642213)}, {"value": Decimal(123.456), "unit": UnitOfEnergy.GIGA_JOULE}, - ] + ], ), } From 664e490cfaf76d29185dcd85b22c72c4f1e21dbc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Oct 2023 19:11:41 +0200 Subject: [PATCH 529/968] Update base image to 2023.10.0 (#102126) --- .hadolint.yaml | 1 + Dockerfile | 16 ++-------------- build.yaml | 10 +++++----- homeassistant/requirements.py | 3 --- homeassistant/util/package.py | 6 ------ tests/test_requirements.py | 5 ----- tests/util/test_package.py | 27 --------------------------- 7 files changed, 8 insertions(+), 60 deletions(-) diff --git a/.hadolint.yaml b/.hadolint.yaml index 06de09b5460..2010a459a5f 100644 --- a/.hadolint.yaml +++ b/.hadolint.yaml @@ -3,3 +3,4 @@ ignored: - DL3008 - DL3013 - DL3018 + - DL3042 diff --git a/Dockerfile b/Dockerfile index f2a365b2b8a..b61e1461c52 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,41 +14,29 @@ COPY requirements.txt homeassistant/ COPY homeassistant/package_constraints.txt homeassistant/homeassistant/ RUN \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ RUN \ if ls homeassistant/home_assistant_frontend*.whl 1> /dev/null 2>&1; then \ - pip3 install \ - --no-cache-dir \ - --no-index \ - homeassistant/home_assistant_frontend-*.whl; \ + pip3 install homeassistant/home_assistant_frontend-*.whl; \ fi \ && if ls homeassistant/home_assistant_intents*.whl 1> /dev/null 2>&1; then \ - pip3 install \ - --no-cache-dir \ - --no-index \ - homeassistant/home_assistant_intents-*.whl; \ + pip3 install homeassistant/home_assistant_intents-*.whl; \ fi \ && \ LD_PRELOAD="/usr/local/lib/libjemalloc.so.2" \ MALLOC_CONF="background_thread:true,metadata_thp:auto,dirty_decay_ms:20000,muzzy_decay_ms:20000" \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core COPY . homeassistant/ RUN \ pip3 install \ - --no-cache-dir \ --only-binary=:all: \ - --index-url "https://wheels.home-assistant.io/musllinux-index/" \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/build.yaml b/build.yaml index f9e19f89e23..e931d193a58 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.09.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.09.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.09.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.09.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.09.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 954de3bf5a6..27a9607a6ee 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -85,11 +85,8 @@ def pip_kwargs(config_dir: str | None) -> dict[str, Any]: is_docker = pkg_util.is_docker_env() kwargs = { "constraints": os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE), - "no_cache_dir": is_docker, "timeout": PIP_TIMEOUT, } - if "WHEELS_LINKS" in os.environ: - kwargs["find_links"] = os.environ["WHEELS_LINKS"] if not (config_dir is None or pkg_util.is_virtual_env()) and not is_docker: kwargs["target"] = os.path.join(config_dir, "deps") return kwargs diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index 7de75c1e24f..bc60953a1aa 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -67,9 +67,7 @@ def install_package( upgrade: bool = True, target: str | None = None, constraints: str | None = None, - find_links: str | None = None, timeout: int | None = None, - no_cache_dir: bool | None = False, ) -> bool: """Install a package on PyPi. Accepts pip compatible package strings. @@ -81,14 +79,10 @@ def install_package( args = [sys.executable, "-m", "pip", "install", "--quiet", package] if timeout: args += ["--timeout", str(timeout)] - if no_cache_dir: - args.append("--no-cache-dir") if upgrade: args.append("--upgrade") if constraints is not None: args += ["--constraint", constraints] - if find_links is not None: - args += ["--find-links", find_links, "--prefer-binary"] if target: assert not is_virtual_env() # This only works if not running in venv diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 388e8607eca..4fa10b92706 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -42,7 +42,6 @@ async def test_requirement_installed_in_venv(hass: HomeAssistant) -> None: "package==0.0.1", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=False, ) @@ -64,7 +63,6 @@ async def test_requirement_installed_in_deps(hass: HomeAssistant) -> None: target=hass.config.path("deps"), constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=False, ) @@ -379,10 +377,8 @@ async def test_install_with_wheels_index(hass: HomeAssistant) -> None: assert mock_inst.call_args == call( "hello==1.0.0", - find_links="https://wheels.hass.io/test", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=True, ) @@ -406,7 +402,6 @@ async def test_install_on_docker(hass: HomeAssistant) -> None: "hello==1.0.0", constraints=os.path.join("ha_package_path", CONSTRAINT_FILE), timeout=60, - no_cache_dir=True, ) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index ff26cba0dd4..e64ea01ffa8 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -194,33 +194,6 @@ def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv) -> N assert mock_popen.return_value.communicate.call_count == 1 -def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv) -> None: - """Test install with find-links on not installed package.""" - env = mock_env_copy() - link = "https://wheels-repository" - assert package.install_package(TEST_NEW_REQ, False, find_links=link) - assert mock_popen.call_count == 2 - assert mock_popen.mock_calls[0] == call( - [ - mock_sys.executable, - "-m", - "pip", - "install", - "--quiet", - TEST_NEW_REQ, - "--find-links", - link, - "--prefer-binary", - ], - stdin=PIPE, - stdout=PIPE, - stderr=PIPE, - env=env, - close_fds=False, - ) - assert mock_popen.return_value.communicate.call_count == 1 - - async def test_async_get_user_site(mock_env_copy) -> None: """Test async get user site directory.""" deps_dir = "/deps_dir" From d0bff1050271c2d857dd62fa01464c385b6c684b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 07:14:13 -1000 Subject: [PATCH 530/968] Bump dbus-fast to 2.12.0 (#102206) --- 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 04815dc8972..9cdf7f2df35 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.12.0", - "dbus-fast==2.11.1" + "dbus-fast==2.12.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e1ec8425630..a526928da4e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.12.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 -dbus-fast==2.11.1 +dbus-fast==2.12.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.73.0 diff --git a/requirements_all.txt b/requirements_all.txt index c5b17fa6a56..f866e45e0f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -654,7 +654,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.11.1 +dbus-fast==2.12.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 047a0c028b5..1613d79763a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -537,7 +537,7 @@ datadog==0.15.0 datapoint==0.9.8;python_version<'3.12' # homeassistant.components.bluetooth -dbus-fast==2.11.1 +dbus-fast==2.12.0 # homeassistant.components.debugpy debugpy==1.8.0 From 9d6518265cc836a68cc876fd2dc047ab7a1928a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 07:22:06 -1000 Subject: [PATCH 531/968] Bump zeroconf to 0.119.0 (#102207) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index e09d09f588c..8509d8133e2 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["zeroconf"], "quality_scale": "internal", - "requirements": ["zeroconf==0.118.0"] + "requirements": ["zeroconf==0.119.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a526928da4e..005672893e7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 yarl==1.9.2 -zeroconf==0.118.0 +zeroconf==0.119.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index f866e45e0f0..9e84206b8db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2787,7 +2787,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.118.0 +zeroconf==0.119.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1613d79763a..555a07f6c10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2081,7 +2081,7 @@ yt-dlp==2023.9.24 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.118.0 +zeroconf==0.119.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 61104dd726157821a0bb1be5af2c4f3e4881934d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 18 Oct 2023 19:50:09 +0200 Subject: [PATCH 532/968] Bump actions/checkout from 4.1.0 to 4.1.1 (#102248) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 12 ++++++------ .github/workflows/ci.yaml | 28 ++++++++++++++-------------- .github/workflows/translations.yml | 2 +- .github/workflows/wheels.yml | 6 +++--- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 98c3bfebbfc..c73a7bac340 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -24,7 +24,7 @@ jobs: publish: ${{ steps.version.outputs.publish }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 with: fetch-depth: 0 @@ -56,7 +56,7 @@ jobs: if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.1 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -252,7 +252,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set build additional args run: | @@ -289,7 +289,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -327,7 +327,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.2 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c7e7d5642e8..7a5c3efd1cb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -91,7 +91,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -224,7 +224,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.1 @@ -269,7 +269,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.1 id: python @@ -337,7 +337,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.1 id: python @@ -386,7 +386,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.1 id: python @@ -480,7 +480,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.1 @@ -548,7 +548,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.1 @@ -580,7 +580,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.1 @@ -613,7 +613,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.1 @@ -657,7 +657,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.1 @@ -739,7 +739,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.1 @@ -891,7 +891,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.1 @@ -1015,7 +1015,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.1 @@ -1110,7 +1110,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download all coverage artifacts uses: actions/download-artifact@v3 - name: Upload coverage to Codecov (full coverage) diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index c35263b3df5..f72b71b8802 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.1 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e1dba9c5452..a51502cd888 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -28,7 +28,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Get information id: info @@ -86,7 +86,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download env_file uses: actions/download-artifact@v3 @@ -124,7 +124,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v4.1.0 + uses: actions/checkout@v4.1.1 - name: Download env_file uses: actions/download-artifact@v3 From b9c7613774f3c1685baf7419923ec0fc576f10d3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 18 Oct 2023 20:49:33 +0200 Subject: [PATCH 533/968] Add switch platform to Comelit SmartHome (#102233) * Add switch platform to Comelit SmartHome * add device class only when needed * apply review comment * small cleanup for light platform * update functions description * fix list of values --- .coveragerc | 1 + homeassistant/components/comelit/__init__.py | 2 +- homeassistant/components/comelit/light.py | 6 +- homeassistant/components/comelit/switch.py | 79 ++++++++++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/comelit/switch.py diff --git a/.coveragerc b/.coveragerc index 29f93c04da6..368af7d1a0f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -178,6 +178,7 @@ omit = homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py + homeassistant/components/comelit/switch.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py homeassistant/components/concord232/binary_sensor.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index 28d87f5b284..c279bcd08f3 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.COVER, Platform.LIGHT] +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 30981cd2820..1bdf3e6a87b 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -49,7 +49,7 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._device = device super().__init__(coordinator) self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" - self._attr_device_info = self.coordinator.platform_device_info(device) + self._attr_device_info = coordinator.platform_device_info(device) async def _light_set_state(self, state: int) -> None: """Set desired light state.""" @@ -61,10 +61,10 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): await self._light_set_state(STATE_ON) async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" + """Turn the light off.""" await self._light_set_state(STATE_OFF) @property def is_on(self) -> bool: - """Return True if entity is on.""" + """Return True if light is on.""" return self.coordinator.data[LIGHT][self._device.index].status == STATE_ON diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py new file mode 100644 index 00000000000..30ef5dc393b --- /dev/null +++ b/homeassistant/components/comelit/switch.py @@ -0,0 +1,79 @@ +"""Support for switches.""" +from __future__ import annotations + +from typing import Any + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import IRRIGATION, OTHER + +from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, STATE_OFF, STATE_ON +from .coordinator import ComelitSerialBridge + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit switches.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + async_add_entities( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in ( + coordinator.data[OTHER].values() + coordinator.data[IRRIGATION].values() + ) + ) + + +class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): + """Switch device.""" + + _attr_has_entity_name = True + _attr_name = None + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + ) -> None: + """Init switch entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device) + if device.type == OTHER: + self._attr_device_class = SwitchDeviceClass.OUTLET + + async def _switch_set_state(self, state: int) -> None: + """Set desired switch state.""" + await self.coordinator.api.set_device_status( + self._device.type, self._device.index, state + ) + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._switch_set_state(STATE_ON) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._switch_set_state(STATE_OFF) + + @property + def is_on(self) -> bool: + """Return True if switch is on.""" + return ( + self.coordinator.data[self._device.type][self._device.index].status + == STATE_ON + ) From 22de378d919b71f631fe9f9d1e3dc61d4b8240bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 08:54:17 -1000 Subject: [PATCH 534/968] Bump bluetooth-data-tools to 1.13.0 (#102208) --- homeassistant/components/bluetooth/manifest.json | 2 +- homeassistant/components/esphome/manifest.json | 2 +- homeassistant/components/ld2410_ble/manifest.json | 2 +- homeassistant/components/led_ble/manifest.json | 2 +- homeassistant/components/private_ble_device/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 9cdf7f2df35..960a86637ae 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.2.1", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", - "bluetooth-data-tools==1.12.0", + "bluetooth-data-tools==1.13.0", "dbus-fast==2.12.0" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index dbcd2042b5a..a024abfe875 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async-interrupt==1.1.1", "aioesphomeapi==18.0.6", - "bluetooth-data-tools==1.12.0", + "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], "zeroconf": ["_esphomelib._tcp.local."] diff --git a/homeassistant/components/ld2410_ble/manifest.json b/homeassistant/components/ld2410_ble/manifest.json index 7971f6bfaf4..f82b2fff62b 100644 --- a/homeassistant/components/ld2410_ble/manifest.json +++ b/homeassistant/components/ld2410_ble/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/ld2410_ble", "integration_type": "device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.12.0", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.13.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 7b936eaad1a..a0f7685a2ec 100644 --- a/homeassistant/components/led_ble/manifest.json +++ b/homeassistant/components/led_ble/manifest.json @@ -32,5 +32,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/led_ble", "iot_class": "local_polling", - "requirements": ["bluetooth-data-tools==1.12.0", "led-ble==1.0.1"] + "requirements": ["bluetooth-data-tools==1.13.0", "led-ble==1.0.1"] } diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json index 9900c854657..91ef843a864 100644 --- a/homeassistant/components/private_ble_device/manifest.json +++ b/homeassistant/components/private_ble_device/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/private_ble_device", "iot_class": "local_push", - "requirements": ["bluetooth-data-tools==1.12.0"] + "requirements": ["bluetooth-data-tools==1.13.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 005672893e7..b9d33abff72 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.2.1 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 -bluetooth-data-tools==1.12.0 +bluetooth-data-tools==1.13.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 diff --git a/requirements_all.txt b/requirements_all.txt index 9e84206b8db..c218adca57e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -562,7 +562,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.12.0 +bluetooth-data-tools==1.13.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 555a07f6c10..e44c0e4eecd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -476,7 +476,7 @@ bluetooth-auto-recovery==1.2.3 # homeassistant.components.ld2410_ble # homeassistant.components.led_ble # homeassistant.components.private_ble_device -bluetooth-data-tools==1.12.0 +bluetooth-data-tools==1.13.0 # homeassistant.components.bond bond-async==0.2.1 From 1372126bc0ef8410327ad1e12b47fe126fd34331 Mon Sep 17 00:00:00 2001 From: Stefan <37924749+stefanroelofs@users.noreply.github.com> Date: Wed, 18 Oct 2023 21:21:55 +0200 Subject: [PATCH 535/968] Remove Shiftr integration (#102224) --- .coveragerc | 1 - homeassistant/components/shiftr/__init__.py | 76 ------------------- homeassistant/components/shiftr/manifest.json | 9 --- homeassistant/generated/integrations.json | 6 -- requirements_all.txt | 1 - requirements_test_all.txt | 1 - 6 files changed, 94 deletions(-) delete mode 100644 homeassistant/components/shiftr/__init__.py delete mode 100644 homeassistant/components/shiftr/manifest.json diff --git a/.coveragerc b/.coveragerc index 368af7d1a0f..1c07c2ea5dd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1118,7 +1118,6 @@ omit = homeassistant/components/sesame/lock.py homeassistant/components/seven_segments/image_processing.py homeassistant/components/seventeentrack/sensor.py - homeassistant/components/shiftr/* homeassistant/components/shodan/sensor.py homeassistant/components/sia/__init__.py homeassistant/components/sia/alarm_control_panel.py diff --git a/homeassistant/components/shiftr/__init__.py b/homeassistant/components/shiftr/__init__.py deleted file mode 100644 index 6f4282915ac..00000000000 --- a/homeassistant/components/shiftr/__init__.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Support for Shiftr.io.""" -import paho.mqtt.client as mqtt -import voluptuous as vol - -from homeassistant.const import ( - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - EVENT_STATE_CHANGED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import state as state_helper -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -DOMAIN = "shiftr" - -SHIFTR_BROKER = "broker.shiftr.io" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Initialize the Shiftr.io MQTT consumer.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - client_id = "HomeAssistant" - port = 1883 - keepalive = 600 - - mqttc = mqtt.Client(client_id, protocol=mqtt.MQTTv311) - mqttc.username_pw_set(username, password=password) - mqttc.connect(SHIFTR_BROKER, port=port, keepalive=keepalive) - - def stop_shiftr(event): - """Stop the Shiftr.io MQTT component.""" - mqttc.disconnect() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_shiftr) - - def shiftr_event_listener(event): - """Listen for new messages on the bus and sends them to Shiftr.io.""" - state = event.data.get("new_state") - topic = state.entity_id.replace(".", "/") - - try: - _state = state_helper.state_as_number(state) - except ValueError: - _state = state.state - - try: - mqttc.publish(topic, _state, qos=0, retain=False) - - if state.attributes: - for attribute, data in state.attributes.items(): - mqttc.publish( - f"/{topic}/{attribute}", str(data), qos=0, retain=False - ) - except RuntimeError: - pass - - hass.bus.listen(EVENT_STATE_CHANGED, shiftr_event_listener) - - return True diff --git a/homeassistant/components/shiftr/manifest.json b/homeassistant/components/shiftr/manifest.json deleted file mode 100644 index 6c524912e77..00000000000 --- a/homeassistant/components/shiftr/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "shiftr", - "name": "shiftr.io", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/shiftr", - "iot_class": "cloud_push", - "loggers": ["paho"], - "requirements": ["paho-mqtt==1.6.1"] -} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 71fddb0a18a..ba426786dff 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5049,12 +5049,6 @@ "config_flow": true, "iot_class": "local_push" }, - "shiftr": { - "name": "shiftr.io", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "shodan": { "name": "Shodan", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c218adca57e..71d734089c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1410,7 +1410,6 @@ ovoenergy==1.2.0 p1monitor==2.1.1 # homeassistant.components.mqtt -# homeassistant.components.shiftr paho-mqtt==1.6.1 # homeassistant.components.panasonic_bluray diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e44c0e4eecd..b0818848dca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1082,7 +1082,6 @@ ovoenergy==1.2.0 p1monitor==2.1.1 # homeassistant.components.mqtt -# homeassistant.components.shiftr paho-mqtt==1.6.1 # homeassistant.components.panasonic_viera From 2531b0bc09dba8d85243e8b7a4942840ef7b64d5 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Wed, 18 Oct 2023 22:00:55 +0200 Subject: [PATCH 536/968] Bump velbusaio to 2023.10.1 (#102178) --- 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 82b66cc0e7f..229ee8458c6 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.0"], + "requirements": ["velbus-aio==2023.10.1"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 71d734089c4..cda3a1ca0ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2659,7 +2659,7 @@ vallox-websocket-api==3.3.0 vehicle==1.0.1 # homeassistant.components.velbus -velbus-aio==2023.10.0 +velbus-aio==2023.10.1 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0818848dca..b5d18465769 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1977,7 +1977,7 @@ vallox-websocket-api==3.3.0 vehicle==1.0.1 # homeassistant.components.velbus -velbus-aio==2023.10.0 +velbus-aio==2023.10.1 # homeassistant.components.venstar venstarcolortouch==0.19 From 606b76c6815ff2faac78bcd6e825ea91b190c4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 18 Oct 2023 23:58:31 +0100 Subject: [PATCH 537/968] Add better connection management for Idasen Desk (#102135) --- .../components/idasen_desk/__init__.py | 78 +++++++++++++++---- .../components/idasen_desk/config_flow.py | 7 +- homeassistant/components/idasen_desk/cover.py | 19 ++--- .../components/idasen_desk/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/idasen_desk/conftest.py | 19 ++++- .../idasen_desk/test_config_flow.py | 11 ++- tests/components/idasen_desk/test_init.py | 17 +++- 9 files changed, 111 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 04b3ef22e1b..27e7e872fd5 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -4,8 +4,9 @@ from __future__ import annotations import logging from attr import dataclass -from bleak import BleakError +from bleak.exc import BleakError from idasen_ha import Desk +from idasen_ha.errors import AuthFailedError from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry @@ -15,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo @@ -28,41 +29,84 @@ PLATFORMS: list[Platform] = [Platform.COVER] _LOGGER = logging.getLogger(__name__) +class IdasenDeskCoordinator(DataUpdateCoordinator): + """Class to manage updates for the Idasen Desk.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + name: str, + address: str, + ) -> None: + """Init IdasenDeskCoordinator.""" + + super().__init__(hass, logger, name=name) + self._address = address + self._expected_connected = False + + self.desk = Desk(self.async_set_updated_data) + + async def async_connect(self) -> bool: + """Connect to desk.""" + _LOGGER.debug("Trying to connect %s", self._address) + ble_device = bluetooth.async_ble_device_from_address( + self.hass, self._address, connectable=True + ) + if ble_device is None: + return False + self._expected_connected = True + await self.desk.connect(ble_device) + return True + + async def async_disconnect(self) -> None: + """Disconnect from desk.""" + _LOGGER.debug("Disconnecting from %s", self._address) + self._expected_connected = False + await self.desk.disconnect() + + @callback + def async_set_updated_data(self, data: int | None) -> None: + """Handle data update.""" + if self._expected_connected: + if not self.desk.is_connected: + _LOGGER.debug("Desk disconnected. Reconnecting") + self.hass.async_create_task(self.async_connect()) + elif self.desk.is_connected: + _LOGGER.warning("Desk is connected but should not be. Disconnecting") + self.hass.async_create_task(self.desk.disconnect()) + return super().async_set_updated_data(data) + + @dataclass class DeskData: """Data for the Idasen Desk integration.""" - desk: Desk address: str device_info: DeviceInfo - coordinator: DataUpdateCoordinator + coordinator: IdasenDeskCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IKEA Idasen from a config entry.""" address: str = entry.data[CONF_ADDRESS].upper() - coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=entry.title, + coordinator: IdasenDeskCoordinator = IdasenDeskCoordinator( + hass, _LOGGER, entry.title, address ) - desk = Desk(coordinator.async_set_updated_data) device_info = DeviceInfo( name=entry.title, connections={(dr.CONNECTION_BLUETOOTH, address)}, ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = DeskData( - desk, address, device_info, coordinator + address, device_info, coordinator ) - ble_device = bluetooth.async_ble_device_from_address( - hass, address, connectable=True - ) try: - await desk.connect(ble_device) - except (TimeoutError, BleakError) as ex: + if not await coordinator.async_connect(): + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") + except (AuthFailedError, TimeoutError, BleakError, Exception) as ex: raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -70,7 +114,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_stop(event: Event) -> None: """Close the connection.""" - await desk.disconnect() + await coordinator.async_disconnect() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) @@ -89,7 +133,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): data: DeskData = hass.data[DOMAIN].pop(entry.entry_id) - await data.desk.disconnect() + await data.coordinator.async_disconnect() bluetooth.async_rediscover_address(hass, data.address) return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py index 92f5a836751..caa8d866fc3 100644 --- a/homeassistant/components/idasen_desk/config_flow.py +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -6,7 +6,8 @@ from typing import Any from bleak.exc import BleakError from bluetooth_data_tools import human_readable_name -from idasen_ha import AuthFailedError, Desk +from idasen_ha import Desk +from idasen_ha.errors import AuthFailedError import voluptuous as vol from homeassistant import config_entries @@ -61,9 +62,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - desk = Desk(None) + desk = Desk(None, monitor_height=False) try: - await desk.connect(discovery_info.device, monitor_height=False) + await desk.connect(discovery_info.device, auto_reconnect=False) except AuthFailedError as err: _LOGGER.exception("AuthFailedError", exc_info=err) errors["base"] = "auth_failed" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index c1d1bb48fd8..94f1b4a8cda 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -1,11 +1,8 @@ """Idasen Desk integration cover platform.""" from __future__ import annotations -import logging from typing import Any -from idasen_ha import Desk - from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -17,16 +14,11 @@ from homeassistant.const import ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import DeskData +from . import DeskData, IdasenDeskCoordinator from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, @@ -36,7 +28,7 @@ async def async_setup_entry( """Set up the cover platform for Idasen Desk.""" data: DeskData = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [IdasenDeskCover(data.desk, data.address, data.device_info, data.coordinator)] + [IdasenDeskCover(data.address, data.device_info, data.coordinator)] ) @@ -54,14 +46,13 @@ class IdasenDeskCover(CoordinatorEntity, CoverEntity): def __init__( self, - desk: Desk, address: str, device_info: DeviceInfo, - coordinator: DataUpdateCoordinator, + coordinator: IdasenDeskCoordinator, ) -> None: """Initialize an Idasen Desk cover.""" super().__init__(coordinator) - self._desk = desk + self._desk = coordinator.desk self._attr_name = device_info[ATTR_NAME] self._attr_unique_id = address self._attr_device_info = device_info diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json index cdb06cf907d..ed941f4f87d 100644 --- a/homeassistant/components/idasen_desk/manifest.json +++ b/homeassistant/components/idasen_desk/manifest.json @@ -11,5 +11,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/idasen_desk", "iot_class": "local_push", - "requirements": ["idasen-ha==1.4.1"] + "requirements": ["idasen-ha==2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index cda3a1ca0ec..26ecd839da9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1051,7 +1051,7 @@ ical==5.0.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4.1 +idasen-ha==2.3 # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b5d18465769..1f906afb7ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -831,7 +831,7 @@ ical==5.0.1 icmplib==3.0 # homeassistant.components.idasen_desk -idasen-ha==1.4.1 +idasen-ha==2.3 # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py index 736bc6346ce..d6c2ba5ad6b 100644 --- a/tests/components/idasen_desk/conftest.py +++ b/tests/components/idasen_desk/conftest.py @@ -10,6 +10,10 @@ import pytest @pytest.fixture(autouse=True) def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address" + ): + yield MagicMock() @pytest.fixture(autouse=False) @@ -18,14 +22,22 @@ def mock_desk_api(): with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: mock_desk = MagicMock() - def mock_init(update_callback: Callable[[int | None], None] | None): + def mock_init( + update_callback: Callable[[int | None], None] | None, + monitor_height: bool = True, + ): mock_desk.trigger_update_callback = update_callback return mock_desk desk_patched.side_effect = mock_init - async def mock_connect(ble_device, monitor_height: bool = True): + async def mock_connect(ble_device): mock_desk.is_connected = True + mock_desk.trigger_update_callback(None) + + async def mock_disconnect(): + mock_desk.is_connected = False + mock_desk.trigger_update_callback(None) async def mock_move_to(height: float): mock_desk.height_percent = height @@ -38,12 +50,13 @@ def mock_desk_api(): await mock_move_to(0) mock_desk.connect = AsyncMock(side_effect=mock_connect) - mock_desk.disconnect = AsyncMock() + mock_desk.disconnect = AsyncMock(side_effect=mock_disconnect) mock_desk.move_to = AsyncMock(side_effect=mock_move_to) mock_desk.move_up = AsyncMock(side_effect=mock_move_up) mock_desk.move_down = AsyncMock(side_effect=mock_move_down) mock_desk.stop = AsyncMock() mock_desk.height_percent = 60 mock_desk.is_moving = False + mock_desk.address = "AA:BB:CC:DD:EE:FF" yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py index 223ecc55e28..ca585c65e4d 100644 --- a/tests/components/idasen_desk/test_config_flow.py +++ b/tests/components/idasen_desk/test_config_flow.py @@ -1,8 +1,8 @@ """Test the IKEA Idasen Desk config flow.""" -from unittest.mock import patch +from unittest.mock import ANY, patch -from bleak import BleakError -from idasen_ha import AuthFailedError +from bleak.exc import BleakError +from idasen_ha.errors import AuthFailedError import pytest from homeassistant import config_entries @@ -260,7 +260,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch("homeassistant.components.idasen_desk.config_flow.Desk.connect"), patch( + with patch( + "homeassistant.components.idasen_desk.config_flow.Desk.connect" + ) as desk_connect, patch( "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" ), patch( "homeassistant.components.idasen_desk.async_setup_entry", @@ -281,3 +283,4 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: } assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address assert len(mock_setup_entry.mock_calls) == 1 + desk_connect.assert_called_with(ANY, auto_reconnect=False) diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py index e596f0fe000..cc8daaf98ea 100644 --- a/tests/components/idasen_desk/test_init.py +++ b/tests/components/idasen_desk/test_init.py @@ -1,7 +1,9 @@ """Test the IKEA Idasen Desk init.""" +from unittest import mock from unittest.mock import AsyncMock, MagicMock -from bleak import BleakError +from bleak.exc import BleakError +from idasen_ha.errors import AuthFailedError import pytest from homeassistant.components.idasen_desk.const import DOMAIN @@ -28,7 +30,7 @@ async def test_setup_and_shutdown( mock_desk_api.disconnect.assert_called_once() -@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +@pytest.mark.parametrize("exception", [AuthFailedError(), TimeoutError(), BleakError()]) async def test_setup_connect_exception( hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception ) -> None: @@ -39,6 +41,17 @@ async def test_setup_connect_exception( assert entry.state is ConfigEntryState.SETUP_RETRY +async def test_no_ble_device(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: + """Test setup with no BLEDevice from address.""" + with mock.patch( + "homeassistant.components.idasen_desk.bluetooth.async_ble_device_from_address", + return_value=None, + ): + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.SETUP_RETRY + + async def test_unload_entry(hass: HomeAssistant, mock_desk_api: MagicMock) -> None: """Test successful unload of entry.""" entry = await init_integration(hass) From c98c18f25e47a73aaab022a6c3e12e2d72956168 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 19 Oct 2023 01:08:08 +0200 Subject: [PATCH 538/968] Return 'None' for light attributes when off instead of removing them (#101946) --- .../components/google_assistant/trait.py | 6 +- homeassistant/components/light/__init__.py | 83 +++++++++++------ tests/components/blebox/test_light.py | 10 +-- tests/components/bond/test_light.py | 4 +- tests/components/deconz/test_light.py | 6 +- .../elgato/snapshots/test_light.ambr | 2 + tests/components/fritzbox/test_light.py | 2 +- .../snapshots/test_init.ambr | 61 +++++++++++++ .../homekit_controller/test_diagnostics.py | 10 +++ .../homekit_controller/test_light.py | 6 +- .../homematicip_cloud/test_light.py | 10 +-- tests/components/hue/test_light_v1.py | 2 +- tests/components/kulersky/test_light.py | 12 +++ tests/components/light/test_init.py | 89 +++++++++++++++++-- tests/components/light/test_recorder.py | 4 +- tests/components/mqtt/test_light_json.py | 38 ++++---- tests/components/tasmota/test_light.py | 28 +++--- tests/components/template/test_light.py | 22 ++--- tests/components/tplink/test_light.py | 6 +- .../twinkly/snapshots/test_diagnostics.ambr | 1 + .../vesync/snapshots/test_light.ambr | 7 ++ tests/components/yeelight/test_light.py | 40 +++++++++ tests/components/zerproc/test_light.py | 15 ++++ tests/components/zha/test_light.py | 2 +- tests/components/zwave_js/test_light.py | 8 +- 25 files changed, 363 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index a3b6638de11..33f0d7a3329 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1769,8 +1769,10 @@ class ModesTrait(_Trait): elif self.state.domain == humidifier.DOMAIN: if ATTR_MODE in attrs: mode_settings["mode"] = attrs.get(ATTR_MODE) - elif self.state.domain == light.DOMAIN and light.ATTR_EFFECT in attrs: - mode_settings["effect"] = attrs.get(light.ATTR_EFFECT) + elif self.state.domain == light.DOMAIN and ( + effect := attrs.get(light.ATTR_EFFECT) + ): + mode_settings["effect"] = effect if mode_settings: response["on"] = self.state.state not in (STATE_OFF, STATE_UNKNOWN) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index cfcb1e13a07..78cccde5890 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -999,58 +999,83 @@ class LightEntity(ToggleEntity): @property def state_attributes(self) -> dict[str, Any] | None: """Return state attributes.""" - if not self.is_on: - return None - data: dict[str, Any] = {} supported_features = self.supported_features - color_mode = self._light_internal_color_mode + supported_color_modes = self._light_internal_supported_color_modes + color_mode = self._light_internal_color_mode if self.is_on else None - if color_mode not in self._light_internal_supported_color_modes: + if color_mode and color_mode not in supported_color_modes: # Increase severity to warning in 2021.6, reject in 2021.10 _LOGGER.debug( "%s: set to unsupported color_mode: %s, supported_color_modes: %s", self.entity_id, color_mode, - self._light_internal_supported_color_modes, + supported_color_modes, ) data[ATTR_COLOR_MODE] = color_mode - if color_mode in COLOR_MODES_BRIGHTNESS: - data[ATTR_BRIGHTNESS] = self.brightness + if brightness_supported(self.supported_color_modes): + if color_mode in COLOR_MODES_BRIGHTNESS: + data[ATTR_BRIGHTNESS] = self.brightness + else: + data[ATTR_BRIGHTNESS] = None elif supported_features & SUPPORT_BRIGHTNESS: # Backwards compatibility for ambiguous / incomplete states # Add warning in 2021.6, remove in 2021.10 - data[ATTR_BRIGHTNESS] = self.brightness - - if color_mode == ColorMode.COLOR_TEMP: - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if not self.color_temp_kelvin: - data[ATTR_COLOR_TEMP] = None + if self.is_on: + data[ATTR_BRIGHTNESS] = self.brightness else: - data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + data[ATTR_BRIGHTNESS] = None - if color_mode in COLOR_MODES_COLOR or color_mode == ColorMode.COLOR_TEMP: - data.update(self._light_internal_convert_color(color_mode)) - - if supported_features & SUPPORT_COLOR_TEMP and not self.supported_color_modes: + if color_temp_supported(self.supported_color_modes): + if color_mode == ColorMode.COLOR_TEMP: + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if self.color_temp_kelvin: + data[ + ATTR_COLOR_TEMP + ] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) + else: + data[ATTR_COLOR_TEMP] = None + else: + data[ATTR_COLOR_TEMP_KELVIN] = None + data[ATTR_COLOR_TEMP] = None + elif supported_features & SUPPORT_COLOR_TEMP: # Backwards compatibility # Add warning in 2021.6, remove in 2021.10 - data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin - if not self.color_temp_kelvin: - data[ATTR_COLOR_TEMP] = None + if self.is_on: + data[ATTR_COLOR_TEMP_KELVIN] = self.color_temp_kelvin + if self.color_temp_kelvin: + data[ + ATTR_COLOR_TEMP + ] = color_util.color_temperature_kelvin_to_mired( + self.color_temp_kelvin + ) + else: + data[ATTR_COLOR_TEMP] = None else: - data[ATTR_COLOR_TEMP] = color_util.color_temperature_kelvin_to_mired( - self.color_temp_kelvin - ) + data[ATTR_COLOR_TEMP_KELVIN] = None + data[ATTR_COLOR_TEMP] = None + + if color_supported(supported_color_modes) or color_temp_supported( + supported_color_modes + ): + data[ATTR_HS_COLOR] = None + data[ATTR_RGB_COLOR] = None + data[ATTR_XY_COLOR] = None + if ColorMode.RGBW in supported_color_modes: + data[ATTR_RGBW_COLOR] = None + if ColorMode.RGBWW in supported_color_modes: + data[ATTR_RGBWW_COLOR] = None + if color_mode: + data.update(self._light_internal_convert_color(color_mode)) if supported_features & LightEntityFeature.EFFECT: - data[ATTR_EFFECT] = self.effect + data[ATTR_EFFECT] = self.effect if self.is_on else None - return {key: val for key, val in data.items() if val is not None} + return data @property def _light_internal_supported_color_modes(self) -> set[ColorMode] | set[str]: diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index e7733147221..e2184df9820 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -197,7 +197,7 @@ async def test_dimmer_off(dimmer, hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None @pytest.fixture(name="wlightbox_s") @@ -236,7 +236,7 @@ async def test_wlightbox_s_init(wlightbox_s, hass: HomeAssistant) -> None: color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES] assert color_modes == [ColorMode.BRIGHTNESS] - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) @@ -339,8 +339,8 @@ async def test_wlightbox_init(wlightbox, hass: HomeAssistant) -> None: color_modes = state.attributes[ATTR_SUPPORTED_COLOR_MODES] assert color_modes == [ColorMode.RGBW] - assert ATTR_BRIGHTNESS not in state.attributes - assert ATTR_RGBW_COLOR not in state.attributes + assert state.attributes[ATTR_BRIGHTNESS] is None + assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_UNKNOWN device_registry = dr.async_get(hass) @@ -487,7 +487,7 @@ async def test_wlightbox_off(wlightbox, hass: HomeAssistant) -> None: ) state = hass.states.get(entity_id) - assert ATTR_RGBW_COLOR not in state.attributes + assert state.attributes[ATTR_RGBW_COLOR] is None assert state.state == STATE_OFF diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 9cb0fdb8a5d..6cbd43b221b 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -726,7 +726,7 @@ async def test_brightness_support(hass: HomeAssistant) -> None: state = hass.states.get("light.name_1") assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -752,7 +752,7 @@ async def test_brightness_not_supported(hass: HomeAssistant) -> None: state = hass.states.get("light.name_1") assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 63e4e8351b4..f6c4452dac6 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1184,9 +1184,9 @@ async def test_non_color_light_reports_color( await hass.async_block_till_done() # Bug is fixed if we reach this point, but device won't have neither color temp nor color - with pytest.raises(KeyError): - assert hass.states.get("light.group").attributes[ATTR_COLOR_TEMP] - assert hass.states.get("light.group").attributes[ATTR_HS_COLOR] + with pytest.raises(AssertionError): + assert hass.states.get("light.group").attributes.get(ATTR_COLOR_TEMP) is None + assert hass.states.get("light.group").attributes.get(ATTR_HS_COLOR) is None async def test_verify_group_supported_features( diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index 72ae1d7e9b8..e9b3eec9a1b 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -220,6 +220,8 @@ 'attributes': ReadOnlyDict({ 'brightness': 128, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Frenck', 'hs_color': tuple( 358.0, diff --git a/tests/components/fritzbox/test_light.py b/tests/components/fritzbox/test_light.py index 0192ea7bb00..5511b93ac3f 100644 --- a/tests/components/fritzbox/test_light.py +++ b/tests/components/fritzbox/test_light.py @@ -96,7 +96,7 @@ async def test_setup_non_color_non_level(hass: HomeAssistant, fritz: Mock) -> No assert state assert state.state == STATE_ON assert state.attributes[ATTR_FRIENDLY_NAME] == "fake_name" - assert state.attributes[ATTR_BRIGHTNESS] == 100 + assert ATTR_BRIGHTNESS not in state.attributes assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == ["onoff"] diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index 1517862664d..d37676e7edf 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -1646,11 +1646,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Aqara Hub-1563 Lightbulb-1563', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.aqara_hub_1563_lightbulb_1563', 'state': 'off', @@ -2027,11 +2032,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'ArloBabyA0 Nightlight', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.arlobabya0_nightlight', 'state': 'off', @@ -7231,15 +7241,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_4', 'state': 'off', @@ -7348,15 +7365,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_3', 'state': 'off', @@ -7465,15 +7489,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle_2', 'state': 'off', @@ -7582,15 +7613,22 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Hue ambiance candle', + 'hs_color': None, 'max_color_temp_kelvin': 6535, 'max_mireds': 454, 'min_color_temp_kelvin': 2202, 'min_mireds': 153, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.hue_ambiance_candle', 'state': 'off', @@ -8250,6 +8288,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8359,6 +8399,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8468,6 +8510,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8577,6 +8621,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8686,6 +8732,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8795,6 +8843,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -8904,6 +8954,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Hue white lamp', 'supported_color_modes': list([ , @@ -9082,11 +9134,16 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Koogeek-LS1-20833F Light Strip', + 'hs_color': None, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'entity_id': 'light.koogeek_ls1_20833f_light_strip', 'state': 'off', @@ -10544,6 +10601,8 @@ }), 'state': dict({ 'attributes': dict({ + 'brightness': None, + 'color_mode': None, 'friendly_name': 'Mysa-85dda9 Display', 'supported_color_modes': list([ , @@ -10828,6 +10887,8 @@ 'attributes': dict({ 'brightness': 255.0, 'color_mode': , + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Nanoleaf Strip 3B32 Nanoleaf Light Strip', 'hs_color': tuple( 30.0, diff --git a/tests/components/homekit_controller/test_diagnostics.py b/tests/components/homekit_controller/test_diagnostics.py index 7fd5b11d5d6..4b5372d980d 100644 --- a/tests/components/homekit_controller/test_diagnostics.py +++ b/tests/components/homekit_controller/test_diagnostics.py @@ -270,6 +270,11 @@ async def test_config_entry( "friendly_name": "Koogeek-LS1-20833F Light Strip", "supported_color_modes": ["hs"], "supported_features": 0, + "brightness": None, + "color_mode": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, }, "entity_id": "light.koogeek_ls1_20833f_light_strip", "last_changed": ANY, @@ -541,6 +546,11 @@ async def test_device( "friendly_name": "Koogeek-LS1-20833F Light Strip", "supported_color_modes": ["hs"], "supported_features": 0, + "brightness": None, + "color_mode": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, }, "entity_id": "light.koogeek_ls1_20833f_light_strip", "last_changed": ANY, diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 3187808a0c5..d6b36fca22e 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -114,7 +114,7 @@ async def test_switch_read_light_state_dimmer(hass: HomeAssistant, utcnow) -> No # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -177,7 +177,7 @@ async def test_switch_read_light_state_hs(hass: HomeAssistant, utcnow) -> None: # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -246,7 +246,7 @@ async def test_switch_read_light_state_color_temp(hass: HomeAssistant, utcnow) - # Initial state is that the light is off state = await helper.poll_and_get_state() assert state.state == "off" - assert ATTR_COLOR_MODE not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.COLOR_TEMP] assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 8f0200373d2..517978e74c0 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -55,7 +55,7 @@ async def test_hmip_light(hass: HomeAssistant, default_mock_hap_factory) -> None await async_manipulate_test_data(hass, hmip_device, "on", False) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -87,7 +87,7 @@ async def test_hmip_notification_light( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.HS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION service_call_counter = len(hmip_device.mock_calls) @@ -184,7 +184,7 @@ async def test_hmip_dimmer(hass: HomeAssistant, default_mock_hap_factory) -> Non ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) @@ -244,7 +244,7 @@ async def test_hmip_light_measuring( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) @@ -290,7 +290,7 @@ async def test_hmip_wired_multi_dimmer( ) assert ha_state.state == STATE_OFF - assert ATTR_COLOR_MODE not in ha_state.attributes + assert ha_state.attributes[ATTR_COLOR_MODE] is None assert ha_state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] assert ha_state.attributes[ATTR_SUPPORTED_FEATURES] == 0 service_call_counter = len(hmip_device.mock_calls) diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index abdbb816364..919f95b6a66 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -242,7 +242,7 @@ async def test_lights_color_mode(hass: HomeAssistant, mock_bridge_v1) -> None: assert lamp_1.state == "on" assert lamp_1.attributes["brightness"] == 145 assert lamp_1.attributes["hs_color"] == (36.067, 69.804) - assert "color_temp" not in lamp_1.attributes + assert lamp_1.attributes["color_temp"] is None assert lamp_1.attributes["color_mode"] == ColorMode.HS assert lamp_1.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, diff --git a/tests/components/kulersky/test_light.py b/tests/components/kulersky/test_light.py index 66c9e3d2147..b9cad7c5f9c 100644 --- a/tests/components/kulersky/test_light.py +++ b/tests/components/kulersky/test_light.py @@ -71,6 +71,12 @@ async def test_init(hass: HomeAssistant, mock_light) -> None: ATTR_FRIENDLY_NAME: "Bedroom", ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGBW], ATTR_SUPPORTED_FEATURES: 0, + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, + ATTR_RGBW_COLOR: None, } with patch.object(hass.loop, "stop"): @@ -191,6 +197,12 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_FRIENDLY_NAME: "Bedroom", ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGBW], ATTR_SUPPORTED_FEATURES: 0, + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_RGBW_COLOR: None, + ATTR_XY_COLOR: None, } # Test an exception during discovery diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 2dc8e504898..675057899b0 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1151,28 +1151,28 @@ async def test_light_backwards_compatibility_supported_color_modes( state = hass.states.get(entity0.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.ONOFF] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.ONOFF state = hass.states.get(entity1.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.BRIGHTNESS] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN state = hass.states.get(entity2.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.COLOR_TEMP] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN state = hass.states.get(entity3.entity_id) assert state.attributes["supported_color_modes"] == [light.ColorMode.HS] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN @@ -1182,7 +1182,7 @@ async def test_light_backwards_compatibility_supported_color_modes( light.ColorMode.HS, ] if light_state == STATE_OFF: - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None else: assert state.attributes["color_mode"] == light.ColorMode.UNKNOWN @@ -1285,6 +1285,79 @@ async def test_light_service_call_rgbw( assert data == {"brightness": 255, "rgbw_color": (10, 20, 30, 40)} +async def test_light_state_off( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test rgbw color conversion in state updates.""" + platform = getattr(hass.components, "test.light") + platform.init(empty=True) + + platform.ENTITIES.append(platform.MockLight("Test_onoff", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_brightness", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_ct", STATE_OFF)) + platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_OFF)) + + entity0 = platform.ENTITIES[0] + entity0.supported_color_modes = {light.ColorMode.ONOFF} + entity1 = platform.ENTITIES[1] + entity1.supported_color_modes = {light.ColorMode.BRIGHTNESS} + entity2 = platform.ENTITIES[2] + entity2.supported_color_modes = {light.ColorMode.COLOR_TEMP} + entity3 = platform.ENTITIES[3] + entity3.supported_color_modes = {light.ColorMode.RGBW} + + assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_onoff", + "supported_color_modes": [light.ColorMode.ONOFF], + "supported_features": 0, + } + + state = hass.states.get(entity1.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_brightness", + "supported_color_modes": [light.ColorMode.BRIGHTNESS], + "supported_features": 0, + "brightness": None, + } + + state = hass.states.get(entity2.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_ct", + "supported_color_modes": [light.ColorMode.COLOR_TEMP], + "supported_features": 0, + "brightness": None, + "color_temp": None, + "color_temp_kelvin": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "max_color_temp_kelvin": 6500, + "max_mireds": 500, + "min_color_temp_kelvin": 2000, + "min_mireds": 153, + } + + state = hass.states.get(entity3.entity_id) + assert state.attributes == { + "color_mode": None, + "friendly_name": "Test_rgbw", + "supported_color_modes": [light.ColorMode.RGBW], + "supported_features": 0, + "brightness": None, + "rgbw_color": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + } + + async def test_light_state_rgbw( hass: HomeAssistant, enable_custom_integrations: None ) -> None: @@ -1295,6 +1368,7 @@ async def test_light_state_rgbw( platform.ENTITIES.append(platform.MockLight("Test_rgbw", STATE_ON)) entity0 = platform.ENTITIES[0] + entity0.brightness = 255 entity0.supported_color_modes = {light.ColorMode.RGBW} entity0.color_mode = light.ColorMode.RGBW entity0.hs_color = "Invalid" # Should be ignored @@ -1316,6 +1390,7 @@ async def test_light_state_rgbw( "rgb_color": (3, 3, 4), "rgbw_color": (1, 2, 3, 4), "xy_color": (0.301, 0.295), + "brightness": 255, } @@ -1336,12 +1411,13 @@ async def test_light_state_rgbww( entity0.rgbw_color = "Invalid" # Should be ignored entity0.rgbww_color = (1, 2, 3, 4, 5) entity0.xy_color = "Invalid" # Should be ignored + entity0.brightness = 255 assert await async_setup_component(hass, "light", {"light": {"platform": "test"}}) await hass.async_block_till_done() state = hass.states.get(entity0.entity_id) - assert dict(state.attributes) == { + assert state.attributes == { "color_mode": light.ColorMode.RGBWW, "friendly_name": "Test_rgbww", "supported_color_modes": [light.ColorMode.RGBWW], @@ -1350,6 +1426,7 @@ async def test_light_state_rgbww( "rgb_color": (5, 5, 4), "rgbww_color": (1, 2, 3, 4, 5), "xy_color": (0.339, 0.354), + "brightness": 255, } diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index edf691b6099..1376ee53649 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components import light from homeassistant.components.light import ( - ATTR_EFFECT, + ATTR_EFFECT_LIST, ATTR_MAX_COLOR_TEMP_KELVIN, ATTR_MAX_MIREDS, ATTR_MIN_COLOR_TEMP_KELVIN, @@ -57,7 +57,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) assert ATTR_MIN_MIREDS not in state.attributes assert ATTR_MAX_MIREDS not in state.attributes assert ATTR_SUPPORTED_COLOR_MODES not in state.attributes - assert ATTR_EFFECT not in state.attributes + assert ATTR_EFFECT_LIST not in state.attributes assert ATTR_FRIENDLY_NAME in state.attributes assert ATTR_MAX_COLOR_TEMP_KELVIN not in state.attributes assert ATTR_MIN_COLOR_TEMP_KELVIN not in state.attributes diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 3b44f86460f..7df4dbc6e82 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -525,7 +525,7 @@ async def test_controlling_state_via_topic( async_fire_mqtt_message(hass, "test_light_rgb", '{"state":"ON", "color_temp":null}') light_state = hass.states.get("light.test") - assert "color_temp" not in light_state.attributes + assert light_state.attributes.get("color_temp") is None async_fire_mqtt_message( hass, "test_light_rgb", '{"state":"ON", "effect":"colorloop"}' @@ -983,8 +983,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (359, 78) assert state.attributes["rgb_color"] == (255, 56, 59) assert state.attributes["xy_color"] == (0.654, 0.301) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator( @@ -1004,8 +1004,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (30.118, 100.0) assert state.attributes["rgb_color"] == (255, 128, 0) assert state.attributes["xy_color"] == (0.611, 0.375) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "color": {"r": 255, "g": 128, "b": 0} }'), @@ -1023,7 +1023,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["rgbw_color"] == (255, 128, 0, 123) assert state.attributes["hs_color"] == (30.0, 67.451) assert state.attributes["rgb_color"] == (255, 169, 83) - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbww_color"] is None assert state.attributes["xy_color"] == (0.526, 0.393) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1044,7 +1044,7 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["rgbww_color"] == (255, 128, 0, 45, 32) assert state.attributes["hs_color"] == (29.872, 92.157) assert state.attributes["rgb_color"] == (255, 137, 20) - assert "rgbw_color" not in state.attributes + assert state.attributes["rgbw_color"] is None assert state.attributes["xy_color"] == (0.596, 0.382) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", @@ -1067,8 +1067,8 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.attributes["hs_color"] == (196.471, 100.0) assert state.attributes["rgb_color"] == (0, 185, 255) assert state.attributes["xy_color"] == (0.123, 0.223) - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator( @@ -1085,11 +1085,11 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON assert state.attributes["brightness"] == 75 assert state.attributes["color_mode"] == "white" - assert "hs_color" not in state.attributes - assert "rgb_color" not in state.attributes - assert "xy_color" not in state.attributes - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["hs_color"] is None + assert state.attributes["rgb_color"] is None + assert state.attributes["xy_color"] is None + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "white": 75}'), @@ -1104,11 +1104,11 @@ async def test_sending_mqtt_commands_and_optimistic2( assert state.state == STATE_ON assert state.attributes["brightness"] == 60 assert state.attributes["color_mode"] == "white" - assert "hs_color" not in state.attributes - assert "rgb_color" not in state.attributes - assert "xy_color" not in state.attributes - assert "rgbw_color" not in state.attributes - assert "rgbww_color" not in state.attributes + assert state.attributes["hs_color"] is None + assert state.attributes["rgb_color"] is None + assert state.attributes["xy_color"] is None + assert state.attributes["rgbw_color"] is None + assert state.attributes["rgbww_color"] is None mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", JsonValidator('{"state": "ON", "white": 60}'), diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 82fa89c5280..27b7bd1a82a 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -351,7 +351,7 @@ async def test_controlling_state_via_mqtt_on_off( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -361,7 +361,7 @@ async def test_controlling_state_via_mqtt_on_off( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/RESULT", '{"POWER":"ON"}') @@ -373,7 +373,7 @@ async def test_controlling_state_via_mqtt_on_off( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async def test_controlling_state_via_mqtt_ct( @@ -402,7 +402,7 @@ async def test_controlling_state_via_mqtt_ct( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -412,7 +412,7 @@ async def test_controlling_state_via_mqtt_ct( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -467,7 +467,7 @@ async def test_controlling_state_via_mqtt_rgbw( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -477,7 +477,7 @@ async def test_controlling_state_via_mqtt_rgbw( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50,"White":0}' @@ -568,7 +568,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -578,7 +578,7 @@ async def test_controlling_state_via_mqtt_rgbww( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -604,7 +604,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color - assert "rgb_color" not in state.attributes + assert not state.attributes.get("hs_color") assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( @@ -621,7 +621,7 @@ async def test_controlling_state_via_mqtt_rgbww( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white to 0 should clear the color_temp - assert "color_temp" not in state.attributes + assert not state.attributes.get("color_temp") assert state.attributes.get("hs_color") == (30, 100) assert state.attributes.get("color_mode") == "hs" @@ -670,7 +670,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF assert not state.attributes.get(ATTR_ASSUMED_STATE) - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON"}') state = hass.states.get("light.tasmota_test") @@ -680,7 +680,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"OFF"}') state = hass.states.get("light.tasmota_test") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert not state.attributes["color_mode"] async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/STATE", '{"POWER":"ON","Dimmer":50}' @@ -716,7 +716,7 @@ async def test_controlling_state_via_mqtt_rgbww_tuya( state = hass.states.get("light.tasmota_test") assert state.state == STATE_ON # Setting white > 0 should clear the color - assert "rgb_color" not in state.attributes + assert not state.attributes.get("hs_color") assert state.attributes.get("color_mode") == "color_temp" async_fire_mqtt_message( diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py index 111580647f5..f807b185c45 100644 --- a/tests/components/template/test_light.py +++ b/tests/components/template/test_light.py @@ -130,7 +130,7 @@ async def test_template_state_invalid( """Test template state with render error.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == supported_color_modes assert state.attributes["supported_features"] == supported_features @@ -163,7 +163,7 @@ async def test_template_state_text(hass: HomeAssistant, setup_light) -> None: await hass.async_block_till_done() state = hass.states.get("light.test_template_light") assert state.state == set_state - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -281,7 +281,7 @@ async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -297,7 +297,7 @@ async def test_on_action(hass: HomeAssistant, setup_light, calls) -> None: assert calls[-1].data["caller"] == "light.test_template_light" assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -341,7 +341,7 @@ async def test_on_action_with_transition( state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == LightEntityFeature.TRANSITION @@ -356,7 +356,7 @@ async def test_on_action_with_transition( assert calls[0].data["transition"] == 5 assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == LightEntityFeature.TRANSITION @@ -383,7 +383,7 @@ async def test_on_action_optimistic( state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -533,7 +533,7 @@ async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> """Test off action with optimistic state.""" state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -547,7 +547,7 @@ async def test_off_action_optimistic(hass: HomeAssistant, setup_light, calls) -> assert len(calls) == 1 state = hass.states.get("light.test_template_light") assert state.state == STATE_OFF - assert "color_mode" not in state.attributes + assert state.attributes["color_mode"] is None assert state.attributes["supported_color_modes"] == [ColorMode.BRIGHTNESS] assert state.attributes["supported_features"] == 0 @@ -921,7 +921,7 @@ async def test_color_and_temperature_actions_no_template( state = hass.states.get("light.test_template_light") assert state.attributes["color_mode"] == ColorMode.HS - assert "color_temp" not in state.attributes + assert state.attributes["color_temp"] is None assert state.attributes["hs_color"] == (40, 50) assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, @@ -964,7 +964,7 @@ async def test_color_and_temperature_actions_no_template( state = hass.states.get("light.test_template_light") assert state.attributes["color_mode"] == ColorMode.HS - assert "color_temp" not in state.attributes + assert state.attributes["color_temp"] is None assert state.attributes["hs_color"] == (10, 20) assert state.attributes["supported_color_modes"] == [ ColorMode.COLOR_TEMP, diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py index cd8494e9b98..348fcc50ce0 100644 --- a/tests/components/tplink/test_light.py +++ b/tests/components/tplink/test_light.py @@ -442,7 +442,7 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_ON - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None strip.is_off = True strip.is_on = False @@ -451,7 +451,7 @@ async def test_smart_strip_effects(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None await hass.services.async_call( LIGHT_DOMAIN, @@ -574,7 +574,7 @@ async def test_smart_strip_custom_random_effect(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert state.state == STATE_OFF - assert ATTR_EFFECT not in state.attributes + assert state.attributes[ATTR_EFFECT] is None await hass.services.async_call( LIGHT_DOMAIN, diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr index cda2ad3d60e..7a7dc2557ef 100644 --- a/tests/components/twinkly/snapshots/test_diagnostics.ambr +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -4,6 +4,7 @@ 'attributes': dict({ 'brightness': 26, 'color_mode': 'brightness', + 'effect': None, 'effect_list': list([ ]), 'friendly_name': 'twinkly_test_device_name', diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 4c33d11564a..9c0c5ae2811 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -419,15 +419,22 @@ # name: test_light_state[Temperature Light][light.temperature_light] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, 'friendly_name': 'Temperature Light', + 'hs_color': None, 'max_color_temp_kelvin': 6493, 'max_mireds': 370, 'min_color_temp_kelvin': 2702, 'min_mireds': 154, + 'rgb_color': None, 'supported_color_modes': list([ , ]), 'supported_features': , + 'xy_color': None, }), 'context': , 'entity_id': 'light.temperature_light', diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py index 47dbd54baa9..441ec202b28 100644 --- a/tests/components/yeelight/test_light.py +++ b/tests/components/yeelight/test_light.py @@ -889,6 +889,7 @@ async def test_device_types( "mono", { "effect_list": YEELIGHT_MONO_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "brightness": bright, "color_mode": "brightness", @@ -903,6 +904,7 @@ async def test_device_types( { "effect_list": YEELIGHT_MONO_EFFECT_LIST, "supported_features": SUPPORT_YEELIGHT, + "effect": None, "brightness": bright, "color_mode": "brightness", "supported_color_modes": ["brightness"], @@ -917,6 +919,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -944,6 +947,7 @@ async def test_device_types( }, nightlight_mode_properties={ "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "hs_color": (28.401, 100.0), "rgb_color": (255, 120, 0), @@ -976,6 +980,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -991,6 +996,8 @@ async def test_device_types( "hs_color": hs_color, "rgb_color": color_hs_to_RGB(*hs_color), "xy_color": color_hs_to_xy(*hs_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1009,6 +1016,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1024,6 +1032,8 @@ async def test_device_types( "hs_color": color_RGB_to_hs(*rgb_color), "rgb_color": rgb_color, "xy_color": color_RGB_to_xy(*rgb_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1043,6 +1053,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1055,6 +1066,11 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": bright, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1074,6 +1090,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1086,6 +1103,11 @@ async def test_device_types( model_specs["color_temp"]["min"] ), "brightness": bright, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1104,6 +1126,7 @@ async def test_device_types( "color", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": model_specs["color_temp"]["min"], "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1115,6 +1138,12 @@ async def test_device_types( "max_mireds": color_temperature_kelvin_to_mired( model_specs["color_temp"]["min"] ), + "brightness": None, + "hs_color": None, + "rgb_color": None, + "xy_color": None, + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "unknown", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1133,6 +1162,7 @@ async def test_device_types( "ceiling1", { "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": color_temperature_mired_to_kelvin( color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) @@ -1163,6 +1193,7 @@ async def test_device_types( }, nightlight_mode_properties={ "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": color_temperature_mired_to_kelvin( color_temperature_kelvin_to_mired(model_specs["color_temp"]["min"]) @@ -1201,6 +1232,7 @@ async def test_device_types( { "friendly_name": NAME, "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, @@ -1234,6 +1266,7 @@ async def test_device_types( nightlight_mode_properties={ "friendly_name": NAME, "effect_list": YEELIGHT_TEMP_ONLY_EFFECT_LIST, + "effect": None, "flowing": False, "night_light": True, "supported_features": SUPPORT_YEELIGHT, @@ -1270,6 +1303,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1297,6 +1331,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1308,6 +1343,8 @@ async def test_device_types( "hs_color": bg_hs_color, "rgb_color": color_hs_to_RGB(*bg_hs_color), "xy_color": color_hs_to_xy(*bg_hs_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "hs", "supported_color_modes": ["color_temp", "hs", "rgb"], }, @@ -1322,6 +1359,7 @@ async def test_device_types( "ceiling4", { "effect_list": YEELIGHT_COLOR_EFFECT_LIST, + "effect": None, "supported_features": SUPPORT_YEELIGHT, "min_color_temp_kelvin": 1700, "max_color_temp_kelvin": color_temperature_mired_to_kelvin( @@ -1333,6 +1371,8 @@ async def test_device_types( "hs_color": color_RGB_to_hs(*bg_rgb_color), "rgb_color": bg_rgb_color, "xy_color": color_RGB_to_xy(*bg_rgb_color), + "color_temp": None, + "color_temp_kelvin": None, "color_mode": "rgb", "supported_color_modes": ["color_temp", "hs", "rgb"], }, diff --git a/tests/components/zerproc/test_light.py b/tests/components/zerproc/test_light.py index a733ab8e5bb..662a75fb7c8 100644 --- a/tests/components/zerproc/test_light.py +++ b/tests/components/zerproc/test_light.py @@ -102,6 +102,11 @@ async def test_init(hass: HomeAssistant, mock_entry) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } state = hass.states.get("light.ledblue_33445566") @@ -283,6 +288,11 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } # Make sure no discovery calls are made while we emulate time passing @@ -320,6 +330,11 @@ async def test_light_update(hass: HomeAssistant, mock_light) -> None: ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_SUPPORTED_FEATURES: 0, ATTR_ICON: "mdi:string-lights", + ATTR_COLOR_MODE: None, + ATTR_BRIGHTNESS: None, + ATTR_HS_COLOR: None, + ATTR_RGB_COLOR: None, + ATTR_XY_COLOR: None, } with patch.object( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index da91340b864..1ec70b74735 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1669,7 +1669,7 @@ async def test_zha_group_light_entity( ColorMode.XY, ] # Light which is off has no color mode - assert "color_mode" not in group_state.attributes + assert group_state.attributes["color_mode"] is None # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index dff9790634e..f5b53f6a76e 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -129,7 +129,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "color_temp" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_COLOR_TEMP] == 370 - assert ATTR_RGB_COLOR in state.attributes + assert state.attributes[ATTR_RGB_COLOR] is not None # Test turning on with same brightness await hass.services.async_call( @@ -254,7 +254,7 @@ async def test_light( assert state.attributes[ATTR_COLOR_MODE] == "hs" assert state.attributes[ATTR_BRIGHTNESS] == 255 assert state.attributes[ATTR_RGB_COLOR] == (255, 76, 255) - assert ATTR_COLOR_TEMP not in state.attributes + assert state.attributes[ATTR_COLOR_TEMP] is None client.async_send_command.reset_mock() @@ -432,8 +432,8 @@ async def test_light( state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) assert state.state == STATE_UNKNOWN - assert ATTR_COLOR_MODE not in state.attributes - assert ATTR_BRIGHTNESS not in state.attributes + assert state.attributes[ATTR_COLOR_MODE] is None + assert state.attributes[ATTR_BRIGHTNESS] is None async def test_v4_dimmer_light( From 20fdd45e602e950a9f7d4aa804e1db977d3a91b6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 13:16:55 -1000 Subject: [PATCH 539/968] Bump home-assistant-bluetooth to 1.10.4 (#102268) --- 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 b9d33abff72..c931a51379a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -21,7 +21,7 @@ fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.73.0 hassil==1.2.5 -home-assistant-bluetooth==1.10.3 +home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231005.0 home-assistant-intents==2023.10.16 httpx==0.25.0 diff --git a/pyproject.toml b/pyproject.toml index 5cc122850ac..5128a06479b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.25.0", - "home-assistant-bluetooth==1.10.3", + "home-assistant-bluetooth==1.10.4", "ifaddr==0.2.0", "Jinja2==3.1.2", "lru-dict==1.2.0", diff --git a/requirements.txt b/requirements.txt index e6359a6bfd1..6379de304e8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.25.0 -home-assistant-bluetooth==1.10.3 +home-assistant-bluetooth==1.10.4 ifaddr==0.2.0 Jinja2==3.1.2 lru-dict==1.2.0 From 159b623b953afa31670657908e91c07bf6abe6f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 13:48:05 -1000 Subject: [PATCH 540/968] Bump orjson to 3.9.9 (#102267) --- 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 c931a51379a..8377bea2e8e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 mutagen==1.47.0 -orjson==3.9.7 +orjson==3.9.9 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.1 diff --git a/pyproject.toml b/pyproject.toml index 5128a06479b..98ebc5e084c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==41.0.4", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.7", + "orjson==3.9.9", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index 6379de304e8..b4f97461bbf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.4 pyOpenSSL==23.2.0 -orjson==3.9.7 +orjson==3.9.9 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From afec5b6730a13037adf019ca6e9f70f49c7fd959 Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 18 Oct 2023 16:51:48 -0700 Subject: [PATCH 541/968] Bump opower to 0.0.37 (#102265) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 02c73238ef9..88e03842504 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.36"] + "requirements": ["opower==0.0.37"] } diff --git a/requirements_all.txt b/requirements_all.txt index 26ecd839da9..1d42a99a67d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1392,7 +1392,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.36 +opower==0.0.37 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f906afb7ef..018a295e9bd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1070,7 +1070,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.36 +opower==0.0.37 # homeassistant.components.oralb oralb-ble==0.17.6 From ae629994570787b7e621934acb07b051000eecd9 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 19 Oct 2023 02:56:15 -0400 Subject: [PATCH 542/968] Bump Python-Roborock to 0.35.0 (#102275) --- homeassistant/components/roborock/manifest.json | 2 +- homeassistant/components/roborock/strings.json | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 6882754f49a..5be48c1f4bf 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.34.6"] + "requirements": ["python-roborock==0.35.0"] } diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 53c536494f9..87d06f92f46 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -64,7 +64,8 @@ "water_empty": "Water empty", "waste_water_tank_full": "Waste water tank full", "dirty_tank_latch_open": "Dirty tank latch open", - "no_dustbin": "No dustbin" + "no_dustbin": "No dustbin", + "cleaning_tank_full_or_blocked": "Cleaning tank full or blocked" } }, "main_brush_time_left": { diff --git a/requirements_all.txt b/requirements_all.txt index 1d42a99a67d..b5733231b2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2179,7 +2179,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.6 +python-roborock==0.35.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 018a295e9bd..bae102058d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1623,7 +1623,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.6 +python-roborock==0.35.0 # homeassistant.components.smarttub python-smarttub==0.0.33 From 4b39d34f4ccd5da7db221cccb41640a11314cce4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 19 Oct 2023 08:56:30 +0200 Subject: [PATCH 543/968] Add CodeQL CI Job (#102273) --- .github/workflows/codeql.yml | 42 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 00000000000..9855f5f4cab --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +# yamllint disable-line rule:truthy +on: + push: + branches: + - dev + - rc + - master + pull_request: + branches: + - dev + schedule: + - cron: "30 18 * * 4" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + timeout-minutes: 360 + permissions: + actions: read + contents: read + security-events: write + + steps: + - name: Check out code from GitHub + uses: actions/checkout@v4.1.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2.22.3 + with: + languages: python + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2.22.3 + with: + category: "/language:python" From 870e38e8ba9265021b6010b16e5afdf7be1539ad Mon Sep 17 00:00:00 2001 From: dupondje Date: Thu, 19 Oct 2023 08:57:50 +0200 Subject: [PATCH 544/968] Remove unused dsmr sensors (#102223) --- homeassistant/components/dsmr/sensor.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 2ff0a834e9e..8159d40d2d5 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -192,7 +192,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="short_power_failure_count", translation_key="short_power_failure_count", obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, @@ -201,7 +201,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="long_power_failure_count", translation_key="long_power_failure_count", obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:flash-off", entity_category=EntityCategory.DIAGNOSTIC, @@ -210,7 +210,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l1_count", translation_key="voltage_sag_l1_count", obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -218,7 +218,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l2_count", translation_key="voltage_sag_l2_count", obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -226,7 +226,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_sag_l3_count", translation_key="voltage_sag_l3_count", obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -234,7 +234,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l1_count", translation_key="voltage_swell_l1_count", obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, @@ -243,7 +243,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l2_count", translation_key="voltage_swell_l2_count", obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, @@ -252,7 +252,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( key="voltage_swell_l3_count", translation_key="voltage_swell_l3_count", obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, - dsmr_versions={"2.2", "4", "5", "5B", "5L"}, + dsmr_versions={"2.2", "4", "5", "5L"}, entity_registry_enabled_default=False, icon="mdi:pulse", entity_category=EntityCategory.DIAGNOSTIC, From 393544b3e7a00c498c0e70d88a6cf91aa5547ccc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 22:41:39 -1000 Subject: [PATCH 545/968] Make group _update_at_start a callback (#102286) --- homeassistant/components/group/__init__.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 364ef15fa5e..82c2651e764 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -526,12 +526,13 @@ class GroupEntity(Entity): self.hass, self._entity_ids, async_state_changed_listener ) ) + self.async_on_remove(start.async_at_start(self.hass, self._update_at_start)) - async def _update_at_start(_: HomeAssistant) -> None: - self.async_update_group_state() - self.async_write_ha_state() - - self.async_on_remove(start.async_at_start(self.hass, _update_at_start)) + @callback + def _update_at_start(self, _: HomeAssistant) -> None: + """Update the group state at start.""" + self.async_update_group_state() + self.async_write_ha_state() @callback def async_defer_or_update_ha_state(self) -> None: From d00934a8f8f63bf13ebe53dc98ab62344982abf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 22:42:15 -1000 Subject: [PATCH 546/968] Refactor automation trigger attachment to avoid creating a closure (#102288) --- homeassistant/components/automation/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index df388e52a7f..84f7f3aca52 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -733,14 +733,14 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.async_write_ha_state() + def _log_callback(self, level: int, msg: str, **kwargs: Any) -> None: + """Log helper callback.""" + self._logger.log(level, "%s %s", msg, self.name, **kwargs) + async def _async_attach_triggers( self, home_assistant_start: bool ) -> Callable[[], None] | None: """Set up the triggers.""" - - def log_cb(level: int, msg: str, **kwargs: Any) -> None: - self._logger.log(level, "%s %s", msg, self.name, **kwargs) - this = None self.async_write_ha_state() if state := self.hass.states.get(self.entity_id): @@ -763,7 +763,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity): self.async_trigger, DOMAIN, str(self.name), - log_cb, + self._log_callback, home_assistant_start, variables, ) From 7ec2496c81c25d2ee3d3339fff0bf09810221b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Thu, 19 Oct 2023 10:42:31 +0200 Subject: [PATCH 547/968] Handle timeouts on AEMET init (#102289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/aemet/__init__.py | 4 ++++ tests/components/aemet/test_init.py | 27 ++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index bcddce5868c..13e636b2196 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,5 +1,6 @@ """The AEMET OpenData component.""" +import asyncio import logging from aemet_opendata.exceptions import TownNotFound @@ -8,6 +9,7 @@ from aemet_opendata.interface import AEMET, ConnectionOptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from .const import ( @@ -37,6 +39,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except TownNotFound as err: _LOGGER.error(err) return False + except asyncio.TimeoutError as err: + raise ConfigEntryNotReady("AEMET OpenData API timed out") from err weather_coordinator = WeatherUpdateCoordinator(hass, aemet) await weather_coordinator.async_config_entry_first_refresh() diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 5055575e3fe..9389acf07c9 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -1,4 +1,5 @@ """Define tests for the AEMET OpenData init.""" +import asyncio from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory @@ -70,3 +71,29 @@ async def test_init_town_not_found( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +async def test_init_api_timeout( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, +) -> None: + """Test API timeouts when loading the AEMET integration.""" + + hass.config.set_time_zone("UTC") + freezer.move_to("2021-01-09 12:00:00+00:00") + with patch( + "homeassistant.components.aemet.AEMET.api_call", + side_effect=asyncio.TimeoutError, + ): + config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "api-key", + CONF_LATITUDE: "0.0", + CONF_LONGITUDE: "0.0", + CONF_NAME: "AEMET", + }, + ) + config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(config_entry.entry_id) is False From 327bdce561c3769f3383355c5d936c732c83defd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 18 Oct 2023 22:42:39 -1000 Subject: [PATCH 548/968] Handle ATTR_HS_COLOR being None in HomeKit (#102290) --- .../components/homekit/type_lights.py | 5 ++- tests/components/homekit/test_type_lights.py | 34 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 13301c3f507..b45e9e1c17b 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -274,8 +274,11 @@ class Light(HomeAccessory): hue, saturation = color_temperature_to_hs(color_temp) elif color_mode == ColorMode.WHITE: hue, saturation = 0, 0 + elif hue_sat := attributes.get(ATTR_HS_COLOR): + hue, saturation = hue_sat else: - hue, saturation = attributes.get(ATTR_HS_COLOR, (None, None)) + hue = None + saturation = None if isinstance(hue, (int, float)) and isinstance(saturation, (int, float)): self.char_hue.set_value(round(hue, 0)) self.char_saturation.set_value(round(saturation, 0)) diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index b023b7255a8..6fae8337aae 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1031,6 +1031,40 @@ async def test_light_rgb_with_white_switch_to_temp( assert acc.char_brightness.value == 100 +async def test_light_rgb_with_hs_color_none( + hass: HomeAssistant, + hk_driver, + events, +) -> None: + """Test lights hs color set to None.""" + entity_id = "light.demo" + + hass.states.async_set( + entity_id, + STATE_ON, + { + ATTR_SUPPORTED_COLOR_MODES: [ColorMode.RGB], + ATTR_RGBWW_COLOR: (128, 50, 0, 255, 255), + ATTR_RGB_COLOR: (128, 50, 0), + ATTR_HS_COLOR: None, + ATTR_BRIGHTNESS: 255, + ATTR_COLOR_MODE: ColorMode.RGB, + }, + ) + await hass.async_block_till_done() + acc = Light(hass, hk_driver, "Light", entity_id, 1, None) + hk_driver.add_accessory(acc) + + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + await acc.run() + await hass.async_block_till_done() + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + assert acc.char_brightness.value == 100 + + async def test_light_rgbww_with_color_temp_conversion( hass: HomeAssistant, hk_driver, From ea61160fd8a741320aa2e427ff9323b31cf6e72d Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Thu, 19 Oct 2023 10:55:30 +0200 Subject: [PATCH 549/968] Reuse function to check feature support on ViCare devices (#102211) * check feature support in utils function * rename parameters * restore severity * reorder parameters * Update .coveragerc --- .coveragerc | 1 + homeassistant/components/vicare/__init__.py | 3 +- .../components/vicare/binary_sensor.py | 31 +++++++++---------- homeassistant/components/vicare/button.py | 31 +++++++++---------- homeassistant/components/vicare/sensor.py | 31 +++++++++---------- homeassistant/components/vicare/utils.py | 26 ++++++++++++++++ 6 files changed, 70 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/vicare/utils.py diff --git a/.coveragerc b/.coveragerc index 1c07c2ea5dd..e682e1741e8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1473,6 +1473,7 @@ omit = homeassistant/components/vicare/climate.py homeassistant/components/vicare/entity.py homeassistant/components/vicare/sensor.py + homeassistant/components/vicare/utils.py homeassistant/components/vicare/water_heater.py homeassistant/components/vilfo/__init__.py homeassistant/components/vilfo/sensor.py diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py index fcfe6497507..7a297ca8113 100644 --- a/homeassistant/components/vicare/__init__.py +++ b/homeassistant/components/vicare/__init__.py @@ -40,10 +40,9 @@ class ViCareRequiredKeysMixin: @dataclass() -class ViCareRequiredKeysMixinWithSet: +class ViCareRequiredKeysMixinWithSet(ViCareRequiredKeysMixin): """Mixin for required keys with setter.""" - value_getter: Callable[[Device], bool] value_setter: Callable[[Device], bool] diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index c45192f05b4..4e3d8d05f97 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -24,6 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -102,25 +104,20 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = ( def _build_entity( - name: str, vicare_api, device_config, sensor: ViCareBinarySensorEntityDescription + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareBinarySensorEntityDescription, ): """Create a ViCare binary sensor entity.""" - try: - sensor.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareBinarySensor( - name, - vicare_api, - device_config, - sensor, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareBinarySensor( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def _entities_from_descriptions( diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index 4cbcf811fbc..2516446a94e 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -5,6 +5,7 @@ from contextlib import suppress from dataclasses import dataclass import logging +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -21,6 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixinWithSet from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -47,26 +49,21 @@ BUTTON_DESCRIPTIONS: tuple[ViCareButtonEntityDescription, ...] = ( def _build_entity( - name: str, vicare_api, device_config, description: ViCareButtonEntityDescription + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareButtonEntityDescription, ): """Create a ViCare button entity.""" _LOGGER.debug("Found device %s", name) - try: - description.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareButton( - name, - vicare_api, - device_config, - description, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareButton( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def async_setup_entry( diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index a7aa93f30bb..325f3bf2d07 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -7,6 +7,7 @@ from dataclasses import dataclass import logging from PyViCare.PyViCareDevice import Device +from PyViCare.PyViCareDeviceConfig import PyViCareDeviceConfig from PyViCare.PyViCareUtils import ( PyViCareInvalidDataError, PyViCareNotSupportedFeatureError, @@ -42,6 +43,7 @@ from .const import ( VICARE_UNIT_TO_UNIT_OF_MEASUREMENT, ) from .entity import ViCareEntity +from .utils import is_supported _LOGGER = logging.getLogger(__name__) @@ -574,26 +576,21 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( def _build_entity( - name: str, vicare_api, device_config, sensor: ViCareSensorEntityDescription + name: str, + vicare_api, + device_config: PyViCareDeviceConfig, + entity_description: ViCareSensorEntityDescription, ): """Create a ViCare sensor entity.""" _LOGGER.debug("Found device %s", name) - try: - sensor.value_getter(vicare_api) - _LOGGER.debug("Found entity %s", name) - except PyViCareNotSupportedFeatureError: - _LOGGER.info("Feature not supported %s", name) - return None - except AttributeError: - _LOGGER.debug("Attribute Error %s", name) - return None - - return ViCareSensor( - name, - vicare_api, - device_config, - sensor, - ) + if is_supported(name, entity_description, vicare_api): + return ViCareSensor( + name, + vicare_api, + device_config, + entity_description, + ) + return None async def _entities_from_descriptions( diff --git a/homeassistant/components/vicare/utils.py b/homeassistant/components/vicare/utils.py new file mode 100644 index 00000000000..19a75c00962 --- /dev/null +++ b/homeassistant/components/vicare/utils.py @@ -0,0 +1,26 @@ +"""ViCare helpers functions.""" +import logging + +from PyViCare.PyViCareUtils import PyViCareNotSupportedFeatureError + +from . import ViCareRequiredKeysMixin + +_LOGGER = logging.getLogger(__name__) + + +def is_supported( + name: str, + entity_description: ViCareRequiredKeysMixin, + vicare_device, +) -> bool: + """Check if the PyViCare device supports the requested sensor.""" + try: + entity_description.value_getter(vicare_device) + _LOGGER.debug("Found entity %s", name) + except PyViCareNotSupportedFeatureError: + _LOGGER.info("Feature not supported %s", name) + return False + except AttributeError as error: + _LOGGER.debug("Attribute Error %s: %s", name, error) + return False + return True From 857f2e1d869302c4b8f871dcdedd514ed1350ded Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 19 Oct 2023 11:33:31 +0200 Subject: [PATCH 550/968] Patch platform in Withings sensor test (#102155) * Patch platform in Withings sensor test * Fix feedback --- tests/components/withings/test_sensor.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 8351598df69..2e5be3e74aa 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -1,6 +1,6 @@ """Tests for the Withings component.""" from datetime import timedelta -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from aiowithings import MeasurementGroup from freezegun.api import FrozenDateTimeFactory @@ -29,16 +29,16 @@ async def test_all_entities( polling_config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration(hass, polling_config_entry) - entity_registry = er.async_get(hass) - entities = er.async_entries_for_config_entry( - entity_registry, polling_config_entry.entry_id - ) + with patch("homeassistant.components.withings.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, polling_config_entry) + entity_registry = er.async_get(hass) + entity_entries = er.async_entries_for_config_entry( + entity_registry, polling_config_entry.entry_id + ) - for entity in entities: - if entity.domain == Platform.SENSOR: - assert hass.states.get(entity.entity_id) == snapshot - assert entities + assert entity_entries + for entity_entry in entity_entries: + assert hass.states.get(entity_entry.entity_id) == snapshot async def test_update_failed( From c377cf1ce0478dd793be9daaca37c8298f817929 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 12:06:33 +0200 Subject: [PATCH 551/968] Do not fail mqtt entry on single platform config schema error (#101373) * Do not fail mqtt entry on platform config * Raise on reload with invalid config * Do not store issues * Follow up --- homeassistant/components/mqtt/__init__.py | 49 ++++++++ .../components/mqtt/alarm_control_panel.py | 27 ++--- .../components/mqtt/config_integration.py | 6 +- homeassistant/components/mqtt/mixins.py | 113 ++++++++++++++++++ homeassistant/components/mqtt/models.py | 3 + homeassistant/components/mqtt/strings.json | 4 + .../mqtt/test_alarm_control_panel.py | 109 +++++++++++++++-- tests/components/mqtt/test_discovery.py | 19 +++ tests/components/mqtt/test_init.py | 28 ++++- 9 files changed, 320 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 7caeb2b51f7..3d3bb486c02 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,9 +29,14 @@ from homeassistant.helpers import config_validation as cv, event as ev, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms +from homeassistant.helpers.issue_registry import ( + async_delete_issue, + async_get as async_get_issue_registry, +) from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration # Loading the config flow file will register the flow from . import debug_info, discovery @@ -209,6 +214,41 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await hass.config_entries.async_reload(entry.entry_id) +@callback +def _async_remove_mqtt_issues(hass: HomeAssistant, mqtt_data: MqttData) -> None: + """Unregister open config issues.""" + issue_registry = async_get_issue_registry(hass) + open_issues = [ + issue_id + for (domain, issue_id), issue_entry in issue_registry.issues.items() + if domain == DOMAIN and issue_entry.translation_key == "invalid_platform_config" + ] + for issue in open_issues: + async_delete_issue(hass, DOMAIN, issue) + + +async def async_check_config_schema( + hass: HomeAssistant, config_yaml: ConfigType +) -> None: + """Validate manually configured MQTT items.""" + mqtt_data = get_mqtt_data(hass) + mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml[DOMAIN] + for mqtt_config_item in mqtt_config: + for domain, config_items in mqtt_config_item.items(): + if (schema := mqtt_data.reload_schema.get(domain)) is None: + continue + for config in config_items: + try: + schema(config) + except vol.Invalid as ex: + integration = await async_get_integration(hass, DOMAIN) + # pylint: disable-next=protected-access + message, _ = conf_util._format_config_error( + ex, domain, config, integration.documentation + ) + raise HomeAssistantError(message) from ex + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" conf: dict[str, Any] @@ -373,6 +413,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Error reloading manually configured MQTT items, " "check your configuration.yaml" ) + # Check the schema before continuing reload + await async_check_config_schema(hass, config_yaml) + + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) + mqtt_data.config = config_yaml.get(DOMAIN, {}) # Reload the modern yaml platforms @@ -594,4 +640,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if subscriptions := mqtt_client.subscriptions: mqtt_data.subscriptions_to_restore = subscriptions + # Remove repair issues + _async_remove_mqtt_issues(hass, mqtt_data) + return True diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a960367ad11..1eb210bf99e 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,7 +1,6 @@ """Control a MQTT alarm.""" from __future__ import annotations -import functools import logging import voluptuous as vol @@ -27,7 +26,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA @@ -44,7 +43,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -133,21 +132,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttAlarm, + alarm.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, alarm.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm(hass, config, config_entry, discovery_data)]) class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 79e977a90cd..975ddfe6386 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -15,7 +15,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from . import ( - alarm_control_panel as alarm_control_panel_platform, binary_sensor as binary_sensor_platform, button as button_platform, camera as camera_platform, @@ -56,10 +55,7 @@ DEFAULT_TLS_PROTOCOL = "auto" CONFIG_SCHEMA_BASE = vol.Schema( { - Platform.ALARM_CONTROL_PANEL.value: vol.All( - cv.ensure_list, - [alarm_control_panel_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] # noqa: E501 - ), + Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]), Platform.BINARY_SENSOR.value: vol.All( cv.ensure_list, [binary_sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a01691f0601..1138663c851 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -9,6 +9,7 @@ import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol +import yaml from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -53,6 +54,7 @@ from homeassistant.helpers.event import ( async_track_device_registry_updated_event, async_track_entity_registry_updated_event, ) +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ( UNDEFINED, ConfigType, @@ -337,6 +339,117 @@ async def async_setup_entry_helper( await _async_setup_entities() +async def async_mqtt_entry_helper( + hass: HomeAssistant, + entry: ConfigEntry, + entity_class: type[MqttEntity], + domain: str, + async_add_entities: AddEntitiesCallback, + discovery_schema: vol.Schema, + platform_schema_modern: vol.Schema, +) -> None: + """Set up entity, automation or tag creation dynamically through MQTT discovery.""" + mqtt_data = get_mqtt_data(hass) + + async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: + """Discover and add an MQTT entity, automation or tag.""" + if not mqtt_config_entry_enabled(hass): + _LOGGER.warning( + ( + "MQTT integration is disabled, skipping setup of discovered item " + "MQTT %s, payload %s" + ), + domain, + discovery_payload, + ) + return + discovery_data = discovery_payload.discovery_data + try: + config: DiscoveryInfoType = discovery_schema(discovery_payload) + async_add_entities([entity_class(hass, config, entry, discovery_data)]) + except vol.Invalid as err: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None + ) + async_handle_schema_error(discovery_payload, err) + except Exception: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send( + hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None + ) + raise + + mqtt_data.reload_dispatchers.append( + async_dispatcher_connect( + hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover + ) + ) + + async def _async_setup_entities() -> None: + """Set up MQTT items from configuration.yaml.""" + mqtt_data = get_mqtt_data(hass) + if not (config_yaml := mqtt_data.config): + return + yaml_configs: list[ConfigType] = [ + config + for config_item in config_yaml + for config_domain, configs in config_item.items() + for config in configs + if config_domain == domain + ] + entities: list[Entity] = [] + for yaml_config in yaml_configs: + try: + config = platform_schema_modern(yaml_config) + entities.append(entity_class(hass, config, entry, None)) + except vol.Invalid as ex: + error = str(ex) + config_file = getattr(yaml_config, "__config_file__", "?") + line = getattr(yaml_config, "__line__", "?") + issue_id = hex(hash(frozenset(yaml_config.items()))) + yaml_config_str = yaml.dump(dict(yaml_config)) + learn_more_url = ( + f"https://www.home-assistant.io/integrations/{domain}.mqtt/" + ) + async_create_issue( + hass, + DOMAIN, + issue_id, + issue_domain=domain, + is_fixable=False, + severity=IssueSeverity.ERROR, + learn_more_url=learn_more_url, + translation_placeholders={ + "domain": domain, + "config_file": config_file, + "line": line, + "config": yaml_config_str, + "error": error, + }, + translation_key="invalid_platform_config", + ) + _LOGGER.error( + "%s for manual configured MQTT %s item, in %s, line %s Got %s", + error, + domain, + config_file, + line, + yaml_config, + ) + + async_add_entities(entities) + + # When reloading we check manual configured items against the schema + # before reloading + mqtt_data.reload_schema[domain] = platform_schema_modern + # discover manual configured MQTT items + mqtt_data.reload_handlers[domain] = _async_setup_entities + await _async_setup_entities() + + def init_entity_id_from_config( hass: HomeAssistant, entity: Entity, config: ConfigType, entity_id_format: str ) -> None: diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 23faa726e09..53442d35cef 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -11,6 +11,8 @@ from enum import StrEnum import logging from typing import TYPE_CHECKING, Any, TypedDict +import voluptuous as vol + from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import template @@ -343,6 +345,7 @@ class MqttData: reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( default_factory=dict ) + reload_schema: dict[str, vol.Schema] = field(default_factory=dict) state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b28f16cb404..9082e034e02 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -19,6 +19,10 @@ "deprecated_climate_aux_property": { "title": "MQTT entities with auxiliary heat support found", "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." + }, + "invalid_platform_config": { + "title": "Invalid configured MQTT {domain} item", + "description": "Home Assistant detected an invalid config for a manual configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 7532319854a..0d5c9ee2e8d 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -22,6 +22,7 @@ from homeassistant.const import ( SERVICE_ALARM_ARM_VACATION, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, + SERVICE_RELOAD, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -36,6 +37,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .test_common import ( help_custom_config, @@ -184,11 +186,9 @@ async def test_fail_setup_without_state_or_command_topic( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, valid ) -> None: """Test for failing setup with no state or command topic.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + state = hass.states.get(f"{alarm_control_panel.DOMAIN}.test") + assert (state is not None) == valid @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) @@ -306,15 +306,13 @@ async def test_supported_features( valid: bool, ) -> None: """Test conditional enablement of supported features.""" + assert await mqtt_mock_entry() + state = hass.states.get("alarm_control_panel.test") if valid: - await mqtt_mock_entry() - assert ( - hass.states.get("alarm_control_panel.test").attributes["supported_features"] - == expected_features - ) + assert state is not None + assert state.attributes["supported_features"] == expected_features else: - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert state is None @pytest.mark.parametrize( @@ -1269,3 +1267,90 @@ async def test_skipped_async_ha_write_state( """Test a write state command is only called when there is change.""" await mqtt_mock_entry() await help_test_skipped_async_ha_write_state(hass, topic, payload1, payload2) + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "invalid_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_after_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test reloading yaml config fails.""" + with patch( + "homeassistant.components.mqtt.async_delete_issue" + ) as mock_async_remove_issue: + assert await mqtt_mock_entry() + assert hass.states.get("alarm_control_panel.test") is None + assert ( + "extra keys not allowed @ data['invalid_topic'] for " + "manual configured MQTT alarm_control_panel item, " + "in ?, line ? Got {'name': 'test', 'invalid_topic': 'test-topic'}" + in caplog.text + ) + + # Reload with an valid config + valid_config = { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "command_topic": "test-topic", + "state_topic": "alarm/state", + } + }, + ] + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=valid_config + ): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Test the config is loaded now and that the existing issue is removed + assert hass.states.get("alarm_control_panel.test") is not None + assert mock_async_remove_issue.call_count == 1 + + # Reload with an invalid config + invalid_config = { + "mqtt": [ + { + "alarm_control_panel": { + "name": "test", + "command_topic": "test-topic", + "invalid_option": "should_fail", + } + }, + ] + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=invalid_config + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + # Make sure the config is loaded now + assert hass.states.get("alarm_control_panel.test") is not None diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index d2f6350a801..863a79fce70 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -145,6 +145,25 @@ async def test_discovery_schema_error( assert "AttributeError: Attribute abc not found" in caplog.text +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) +async def test_invalid_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test sending in JSON that violates the platform schema.""" + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/alarm_control_panel/bla/config", + '{"name": "abc", "state_topic": "home/alarm", ' + '"command_topic": "home/alarm/set", ' + '"qos": "some_invalid_value"}', + ) + await hass.async_block_till_done() + assert "Error 'expected int for dictionary value @ data['qos']'" in caplog.text + + async def test_only_valid_components( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 3cb1188dccf..2c6ee5d1b20 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3547,14 +3547,21 @@ async def test_publish_or_subscribe_without_valid_config_entry( @patch( "homeassistant.components.mqtt.PLATFORMS", - ["tag", Platform.LIGHT], + [Platform.ALARM_CONTROL_PANEL, Platform.LIGHT], ) @pytest.mark.parametrize( "hass_config", [ { "mqtt": { - "light": [{"name": "test", "command_topic": "test-topic"}], + "alarm_control_panel": [ + { + "name": "test", + "state_topic": "home/alarm", + "command_topic": "home/alarm/set", + }, + ], + "light": [{"name": "test", "command_topic": "test-topic_new"}], } } ], @@ -3568,10 +3575,18 @@ async def test_disabling_and_enabling_entry( await mqtt_mock_entry() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] assert entry.state is ConfigEntryState.LOADED - # Late discovery of a mqtt entity/tag + # Late discovery of a mqtt entity config_tag = '{"topic": "0AFFD2/tag_scanned", "value_template": "{{ value_json.PN532.UID }}"}' - config_light = '{"name": "test2", "command_topic": "test-topic_new"}' + config_alarm_control_panel = '{"name": "test_new", "state_topic": "home/alarm", "command_topic": "home/alarm/set"}' + config_light = '{"name": "test_new", "command_topic": "test-topic_new"}' + + # Discovery of mqtt tag async_fire_mqtt_message(hass, "homeassistant/tag/abc/config", config_tag) + + # Late discovery of mqtt entities + async_fire_mqtt_message( + hass, "homeassistant/alarm_control_panel/abc/config", config_alarm_control_panel + ) async_fire_mqtt_message(hass, "homeassistant/light/abc/config", config_light) # Disable MQTT config entry @@ -3585,6 +3600,10 @@ async def test_disabling_and_enabling_entry( "MQTT integration is disabled, skipping setup of discovered item MQTT tag" in caplog.text ) + assert ( + "MQTT integration is disabled, skipping setup of discovered item MQTT alarm_control_panel" + in caplog.text + ) assert ( "MQTT integration is disabled, skipping setup of discovered item MQTT light" in caplog.text @@ -3601,6 +3620,7 @@ async def test_disabling_and_enabling_entry( assert new_mqtt_config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.test") is not None + assert hass.states.get("alarm_control_panel.test") is not None @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) From 9857c0fa3a28d192161376050dde17f5432c86b2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 19 Oct 2023 12:30:40 +0200 Subject: [PATCH 552/968] Move WAQI state attributes to separate sensors (#101217) * Migrate WAQI to has entity name * Split WAQI extra state attributes into separate sensors * Split WAQI extra state attributes into separate sensors * Fix test * Support new aiowaqi * Bump aiowaqi to 2.1.0 * Add nephelometry as possible value * Fix test --- homeassistant/components/waqi/sensor.py | 194 ++++++++++++++---- homeassistant/components/waqi/strings.json | 34 +++ .../waqi/fixtures/air_quality_sensor.json | 6 + .../waqi/snapshots/test_sensor.ambr | 181 ++++++++++++++++ tests/components/waqi/test_sensor.py | 22 +- 5 files changed, 388 insertions(+), 49 deletions(-) create mode 100644 tests/components/waqi/snapshots/test_sensor.ambr diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index e0ecf5827d8..ecc29006c5d 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,24 +1,36 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations +from collections.abc import Callable, Mapping +from dataclasses import dataclass import logging +from typing import Any -from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError +from aiowaqi import ( + WAQIAirQuality, + WAQIAuthenticationError, + WAQIClient, + WAQIConnectionError, +) +from aiowaqi.models import Pollutant import voluptuous as vol from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, CONF_API_KEY, CONF_NAME, CONF_TOKEN, + PERCENTAGE, + UnitOfPressure, + UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady @@ -27,7 +39,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER @@ -141,67 +153,167 @@ async def async_setup_platform( ) +@dataclass +class WAQIMixin: + """Mixin for required keys.""" + + available_fn: Callable[[WAQIAirQuality], bool] + value_fn: Callable[[WAQIAirQuality], StateType] + + +@dataclass +class WAQISensorEntityDescription(SensorEntityDescription, WAQIMixin): + """Describes WAQI sensor entity.""" + + +SENSORS: list[WAQISensorEntityDescription] = [ + WAQISensorEntityDescription( + key="air_quality", + device_class=SensorDeviceClass.AQI, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.air_quality_index, + available_fn=lambda _: True, + ), + WAQISensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.humidity, + available_fn=lambda aq: aq.extended_air_quality.humidity is not None, + ), + WAQISensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.HPA, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pressure, + available_fn=lambda aq: aq.extended_air_quality.pressure is not None, + ), + WAQISensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.temperature, + available_fn=lambda aq: aq.extended_air_quality.temperature is not None, + ), + WAQISensorEntityDescription( + key="carbon_monoxide", + translation_key="carbon_monoxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.carbon_monoxide, + available_fn=lambda aq: aq.extended_air_quality.carbon_monoxide is not None, + ), + WAQISensorEntityDescription( + key="nitrogen_dioxide", + translation_key="nitrogen_dioxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.nitrogen_dioxide, + available_fn=lambda aq: aq.extended_air_quality.nitrogen_dioxide is not None, + ), + WAQISensorEntityDescription( + key="ozone", + translation_key="ozone", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.ozone, + available_fn=lambda aq: aq.extended_air_quality.ozone is not None, + ), + WAQISensorEntityDescription( + key="sulphur_dioxide", + translation_key="sulphur_dioxide", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.sulfur_dioxide, + available_fn=lambda aq: aq.extended_air_quality.sulfur_dioxide is not None, + ), + WAQISensorEntityDescription( + key="pm10", + translation_key="pm10", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pm10, + available_fn=lambda aq: aq.extended_air_quality.pm10 is not None, + ), + WAQISensorEntityDescription( + key="pm25", + translation_key="pm25", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.pm25, + available_fn=lambda aq: aq.extended_air_quality.pm25 is not None, + ), + WAQISensorEntityDescription( + key="dominant_pollutant", + translation_key="dominant_pollutant", + device_class=SensorDeviceClass.ENUM, + options=[pollutant.value for pollutant in Pollutant], + value_fn=lambda aq: aq.dominant_pollutant, + available_fn=lambda _: True, + ), +] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the WAQI sensor.""" coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([WaqiSensor(coordinator)]) + async_add_entities( + [ + WaqiSensor(coordinator, sensor) + for sensor in SENSORS + if sensor.available_fn(coordinator.data) + ] + ) class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" - _attr_icon = ATTR_ICON - _attr_device_class = SensorDeviceClass.AQI - _attr_state_class = SensorStateClass.MEASUREMENT _attr_has_entity_name = True - _attr_name = None + entity_description: WAQISensorEntityDescription - def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: + def __init__( + self, + coordinator: WAQIDataUpdateCoordinator, + entity_description: WAQISensorEntityDescription, + ) -> None: """Initialize the sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator.data.station_id}_air_quality" + self.entity_description = entity_description + self._attr_unique_id = f"{coordinator.data.station_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(coordinator.data.station_id))}, name=coordinator.data.city.name, entry_type=DeviceEntryType.SERVICE, ) + self._attr_attribution = " and ".join( + attribution.name for attribution in coordinator.data.attributions + ) @property - def native_value(self) -> int | None: + def native_value(self) -> StateType: """Return the state of the device.""" - return self.coordinator.data.air_quality_index + return self.entity_description.value_fn(self.coordinator.data) @property - def extra_state_attributes(self): - """Return the state attributes of the last update.""" - attrs = {} - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [ - attribution.name - for attribution in self.coordinator.data.attributions - ] - ) + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return old state attributes if the entity is AQI entity.""" + if self.entity_description.key != "air_quality": + return None + attrs: dict[str, Any] = {} + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant - attrs[ATTR_TIME] = self.coordinator.data.measured_at - attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant + iaqi = self.coordinator.data.extended_air_quality - iaqi = self.coordinator.data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, - ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, - } - res_attributes = {k: v for k, v in attribute.items() if v is not None} - return {**attrs, **res_attributes} - except (IndexError, KeyError): - return {ATTR_ATTRIBUTION: ATTRIBUTION} + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, + ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, + } + res_attributes = {k: v for k, v in attribute.items() if v is not None} + return {**attrs, **res_attributes} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 46031a3072b..54013f3ca2c 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -53,5 +53,39 @@ "title": "The WAQI YAML configuration import failed", "description": "Configuring World Air Quality Index using YAML is being removed but there weren't any stations imported because they couldn't be found.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." } + }, + "entity": { + "sensor": { + "carbon_monoxide": { + "name": "[%key:component::sensor::entity_component::carbon_monoxide::name%]" + }, + "nitrogen_dioxide": { + "name": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]" + }, + "ozone": { + "name": "[%key:component::sensor::entity_component::ozone::name%]" + }, + "sulphur_dioxide": { + "name": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + }, + "pm10": { + "name": "[%key:component::sensor::entity_component::pm10::name%]" + }, + "pm25": { + "name": "[%key:component::sensor::entity_component::pm25::name%]" + }, + "dominant_pollutant": { + "name": "Dominant pollutant", + "state": { + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "neph": "Nephelometry", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "pm25": "[%key:component::sensor::entity_component::pm25::name%]" + } + } + } } } diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json index 49f1184822f..fbc153e4e28 100644 --- a/tests/components/waqi/fixtures/air_quality_sensor.json +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -23,9 +23,15 @@ "h": { "v": 80 }, + "co": { + "v": 2.3 + }, "no2": { "v": 2.3 }, + "so2": { + "v": 2.3 + }, "o3": { "v": 29.4 }, diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..3d4d7f30bbd --- /dev/null +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -0,0 +1,181 @@ +# serializer version: 1 +# name: test_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'aqi', + 'dominentpol': , + 'friendly_name': 'de Jongweg, Utrecht Air quality index', + 'humidity': 80, + 'nitrogen_dioxide': 2.3, + 'ozone': 29.4, + 'pm_10': 12, + 'pm_2_5': 17, + 'pressure': 1008.8, + 'state_class': , + 'sulfur_dioxide': 2.3, + 'temperature': 16, + 'time': datetime.datetime(2023, 8, 7, 17, 0, tzinfo=datetime.timezone(datetime.timedelta(seconds=7200))), + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_air_quality_index', + 'last_changed': , + 'last_updated': , + 'state': '29', + }) +# --- +# name: test_sensor.1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'humidity', + 'friendly_name': 'de Jongweg, Utrecht Humidity', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_humidity', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'enum', + 'friendly_name': 'de Jongweg, Utrecht Dominant pollutant', + 'options': list([ + 'co', + 'no2', + 'o3', + 'so2', + 'pm10', + 'pm25', + 'neph', + ]), + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_dominant_pollutant', + 'last_changed': , + 'last_updated': , + 'state': 'o3', + }) +# --- +# name: test_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'pressure', + 'friendly_name': 'de Jongweg, Utrecht Pressure', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pressure', + 'last_changed': , + 'last_updated': , + 'state': '1008.8', + }) +# --- +# name: test_sensor.3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'device_class': 'temperature', + 'friendly_name': 'de Jongweg, Utrecht Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_temperature', + 'last_changed': , + 'last_updated': , + 'state': '16', + }) +# --- +# name: test_sensor.4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Carbon monoxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_carbon_monoxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Nitrogen dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_nitrogen_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Ozone', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_ozone', + 'last_changed': , + 'last_updated': , + 'state': '29.4', + }) +# --- +# name: test_sensor.7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Sulphur dioxide', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_sulphur_dioxide', + 'last_changed': , + 'last_updated': , + 'state': '2.3', + }) +# --- +# name: test_sensor.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM10', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm10', + 'last_changed': , + 'last_updated': , + 'state': '12', + }) +# --- +# name: test_sensor.9 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht PM2.5', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_pm2_5', + 'last_changed': , + 'last_updated': , + 'state': '17', + }) +# --- diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 46bd577c48f..3d708e6c26d 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -3,10 +3,11 @@ import json from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +from syrupy import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN -from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS +from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS, SENSORS from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState from homeassistant.const import ( CONF_API_KEY, @@ -72,7 +73,7 @@ async def test_legacy_migration_already_imported( assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - state = hass.states.get("sensor.de_jongweg_utrecht") + state = hass.states.get("sensor.de_jongweg_utrecht_air_quality_index") assert state.state == "29" hass.async_create_task( @@ -114,13 +115,15 @@ async def test_sensor_id_migration( entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entities) == 1 + assert len(entities) == 11 assert hass.states.get("sensor.waqi_4584") - assert hass.states.get("sensor.de_jongweg_utrecht") is None + assert hass.states.get("sensor.de_jongweg_utrecht_air_quality_index") is None assert entities[0].unique_id == "4584_air_quality" -async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: +async def test_sensor( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion +) -> None: """Test failed update.""" mock_config_entry.add_to_hass(hass) with patch( @@ -131,9 +134,12 @@ async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) - ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() - - state = hass.states.get("sensor.de_jongweg_utrecht") - assert state.state == "29" + entity_registry = er.async_get(hass) + for sensor in SENSORS: + entity_id = entity_registry.async_get_entity_id( + SENSOR_DOMAIN, DOMAIN, f"4584_{sensor.key}" + ) + assert hass.states.get(entity_id) == snapshot async def test_updating_failed( From 4498c2e8c480cd23ba3167ca01bef83471d0b9e0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Oct 2023 13:34:10 +0200 Subject: [PATCH 553/968] Validate steps in Flowhandler (#102152) * Validate steps in Flowhandler * Move validation to FlowManager._async_handle_step * Fix _raise_if_not_has_step * Fix config_entries tests * Fix tests * Rename * Add test --- homeassistant/data_entry_flow.py | 20 ++++++++----- tests/components/abode/test_init.py | 5 +++- .../components/aussie_broadband/test_init.py | 5 +++- .../components/config/test_config_entries.py | 6 ++++ tests/components/synology_dsm/test_init.py | 5 +++- tests/test_config_entries.py | 24 +++++++++++++++ tests/test_data_entry_flow.py | 29 +++++++++++++++++++ 7 files changed, 84 insertions(+), 10 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 545b799c467..e0ea195a3ff 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -382,14 +382,9 @@ class FlowManager(abc.ABC): self, flow: FlowHandler, step_id: str, user_input: dict | BaseServiceInfo | None ) -> FlowResult: """Handle a step of a flow.""" + self._raise_if_step_does_not_exist(flow, step_id) + method = f"async_step_{step_id}" - - if not hasattr(flow, method): - self._async_remove_flow_progress(flow.flow_id) - raise UnknownStep( - f"Handler {flow.__class__.__name__} doesn't support step {step_id}" - ) - try: result: FlowResult = await getattr(flow, method)(user_input) except AbortFlow as err: @@ -419,6 +414,7 @@ class FlowManager(abc.ABC): FlowResultType.SHOW_PROGRESS_DONE, FlowResultType.MENU, ): + self._raise_if_step_does_not_exist(flow, result["step_id"]) flow.cur_step = result return result @@ -435,6 +431,16 @@ class FlowManager(abc.ABC): return result + def _raise_if_step_does_not_exist(self, flow: FlowHandler, step_id: str) -> None: + """Raise if the step does not exist.""" + method = f"async_step_{step_id}" + + if not hasattr(flow, method): + self._async_remove_flow_progress(flow.flow_id) + raise UnknownStep( + f"Handler {self.__class__.__name__} doesn't support step {step_id}" + ) + async def _async_setup_preview(self, flow: FlowHandler) -> None: """Set up preview for a flow handler.""" if flow.handler not in self._preview: diff --git a/tests/components/abode/test_init.py b/tests/components/abode/test_init.py index 17039235f37..d208b6302bc 100644 --- a/tests/components/abode/test_init.py +++ b/tests/components/abode/test_init.py @@ -77,7 +77,10 @@ async def test_invalid_credentials(hass: HomeAssistant) -> None: ), ), patch( "homeassistant.components.abode.config_flow.AbodeFlowHandler.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: await setup_platform(hass, ALARM_DOMAIN) diff --git a/tests/components/aussie_broadband/test_init.py b/tests/components/aussie_broadband/test_init.py index 3eb1972011c..1430eca3a26 100644 --- a/tests/components/aussie_broadband/test_init.py +++ b/tests/components/aussie_broadband/test_init.py @@ -23,7 +23,10 @@ async def test_auth_failure(hass: HomeAssistant) -> None: """Test init with an authentication failure.""" with patch( "homeassistant.components.aussie_broadband.config_flow.ConfigFlow.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: await setup_platform(hass, side_effect=AuthenticationException()) mock_async_step_reauth.assert_called_once() diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 4239e031893..3cc7ada49ba 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -798,6 +798,9 @@ async def test_options_flow(hass: HomeAssistant, client) -> None: description_placeholders={"enabled": "Set to true to be true"}, ) + async def async_step_user(self, user_input=None): + raise NotImplementedError + return OptionsFlowHandler() mock_integration(hass, MockModule("test")) @@ -1271,6 +1274,9 @@ async def test_ignore_flow( await self.async_set_unique_id("mock-unique-id") return self.async_show_form(step_id="account") + async def async_step_account(self, user_input=None): + raise NotImplementedError + ws_client = await hass_ws_client(hass) with patch.dict(HANDLERS, {"test": TestFlow}): diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index bfc3daf0aa2..91556f459ba 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -50,7 +50,10 @@ async def test_reauth_triggered(hass: HomeAssistant) -> None: side_effect=SynologyDSMLoginInvalidException(USERNAME), ), patch( "homeassistant.components.synology_dsm.config_flow.SynologyDSMFlowHandler.async_step_reauth", - return_value={"type": data_entry_flow.FlowResultType.FORM}, + return_value={ + "type": data_entry_flow.FlowResultType.FORM, + "step_id": "reauth_confirm", + }, ) as mock_async_step_reauth: entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 52caa1ae275..d17c724cb2a 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -2171,6 +2171,9 @@ async def test_manual_add_overrides_ignored_entry( ) return self.async_show_form(step_id="step2") + async def async_step_step2(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( "homeassistant.config_entries.ConfigEntries.async_reload" ) as async_reload: @@ -2500,6 +2503,9 @@ async def test_partial_flows_hidden( await pause_discovery.wait() return self.async_show_form(step_id="someform") + async def async_step_someform(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # Start a config entry flow and wait for it to be blocked init_task = asyncio.ensure_future( @@ -2788,6 +2794,9 @@ async def test_flow_with_default_discovery_with_unique_id( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): result = await manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_DISCOVERY} @@ -2841,6 +2850,9 @@ async def test_default_discovery_in_progress( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): result = await manager.flow.async_init( "comp", @@ -2878,6 +2890,9 @@ async def test_default_discovery_abort_on_new_unique_flow( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # First discovery with default, no unique ID result2 = await manager.flow.async_init( @@ -2922,6 +2937,9 @@ async def test_default_discovery_abort_on_user_flow_complete( await self._async_handle_discovery_without_unique_id() return self.async_show_form(step_id="mock") + async def async_step_mock(self, user_input=None): + raise NotImplementedError + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): # First discovery with default, no unique ID flow1 = await manager.flow.async_init( @@ -3968,6 +3986,9 @@ async def test_preview_supported( """Mock Reauth.""" return self.async_show_form(step_id="next", preview="test") + async def async_step_next(self, user_input=None): + raise NotImplementedError + @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: """Set up preview.""" @@ -4006,6 +4027,9 @@ async def test_preview_not_supported( """Mock Reauth.""" return self.async_show_form(step_id="user_confirm") + async def async_step_user_confirm(self, user_input=None): + raise NotImplementedError + mock_integration(hass, MockModule("test")) mock_entity_platform(hass, "config_flow.test", None) diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index e6a28fc2e4f..98380890e41 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -621,6 +621,35 @@ async def test_move_to_unknown_step_raises_and_removes_from_in_progress( assert manager.async_progress() == [] +@pytest.mark.parametrize( + ("result_type", "params"), + [ + ("async_external_step_done", {"next_step_id": "does_not_exist"}), + ("async_external_step", {"step_id": "does_not_exist", "url": "blah"}), + ("async_show_form", {"step_id": "does_not_exist"}), + ("async_show_menu", {"step_id": "does_not_exist", "menu_options": []}), + ("async_show_progress_done", {"next_step_id": "does_not_exist"}), + ("async_show_progress", {"step_id": "does_not_exist", "progress_action": ""}), + ], +) +async def test_next_step_unknown_step_raises_and_removes_from_in_progress( + manager, result_type: str, params: dict[str, str] +) -> None: + """Test that moving to an unknown step raises and removes the flow from in progress.""" + + @manager.mock_reg_handler("test") + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 1 + + async def async_step_init(self, user_input=None): + return getattr(self, result_type)(**params) + + with pytest.raises(data_entry_flow.UnknownStep): + await manager.async_init("test", context={"init_step": "init"}) + + assert manager.async_progress() == [] + + async def test_configure_raises_unknown_flow_if_not_in_progress(manager) -> None: """Test configure raises UnknownFlow if the flow is not in progress.""" with pytest.raises(data_entry_flow.UnknownFlow): From dff18b4a16ca1695e8dc2715fa3ae658cefdde28 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 19 Oct 2023 15:08:52 +0200 Subject: [PATCH 554/968] Rename `gather_with_concurrency` to `gather_with_limited_concurrency` (#102241) * Rename gather_with_concurrency to gather_with_limited_concurrency * Update test --- homeassistant/components/bond/utils.py | 4 ++-- homeassistant/components/ping/device_tracker.py | 4 ++-- homeassistant/components/tile/__init__.py | 6 ++++-- homeassistant/components/wemo/__init__.py | 6 +++--- homeassistant/helpers/discovery_flow.py | 4 ++-- homeassistant/util/async_.py | 2 +- tests/util/test_async.py | 6 +++--- 7 files changed, 17 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index ade8fd0b91d..60b9a7b492f 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -7,7 +7,7 @@ from typing import Any, cast from aiohttp import ClientResponseError from bond_async import Action, Bond, BondType -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import BRIDGE_MAKE @@ -163,7 +163,7 @@ class BondHub: ] ) - responses = await gather_with_concurrency(MAX_REQUESTS, *tasks) + responses = await gather_with_limited_concurrency(MAX_REQUESTS, *tasks) response_idx = 0 for device_id in setup_device_ids: self._devices.append( diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index f546bd6bacc..a25b3652b36 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.process import kill_subprocess from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_PRIVS, PING_TIMEOUT @@ -117,7 +117,7 @@ async def async_setup_scanner( async def async_update(now: datetime) -> None: """Update all the hosts on every interval time.""" - results = await gather_with_concurrency( + results = await gather_with_limited_concurrency( CONCURRENT_PING_LIMIT, *(hass.async_add_executor_job(host.update) for host in hosts), ) diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py index 29754ffba4b..1e8cebdd5a6 100644 --- a/homeassistant/components/tile/__init__.py +++ b/homeassistant/components/tile/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN, LOGGER @@ -106,7 +106,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator_init_tasks.append(coordinator.async_refresh()) - await gather_with_concurrency(DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks) + await gather_with_limited_concurrency( + DEFAULT_INIT_TASK_LIMIT, *coordinator_init_tasks + ) hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = TileData(coordinators=coordinators, tiles=tiles) diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index a58169aa6e5..3f7cbe4cf45 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -16,7 +16,7 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency from .const import DOMAIN from .models import WemoConfigEntryData, WemoData, async_wemo_data @@ -217,7 +217,7 @@ class WemoDispatcher: """Consider a platform as loaded and dispatch any backlog of discovered devices.""" self._dispatch_callbacks[platform] = dispatch - await gather_with_concurrency( + await gather_with_limited_concurrency( MAX_CONCURRENCY, *( dispatch(coordinator) @@ -289,7 +289,7 @@ class WemoDiscovery: if not self._static_config: return _LOGGER.debug("Adding statically configured WeMo devices") - for device in await gather_with_concurrency( + for device in await gather_with_limited_concurrency( MAX_CONCURRENCY, *( self._hass.async_add_executor_job(validate_static_config, host, port) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 306e8b51d63..c2c9a04b7c3 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -8,7 +8,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.loader import bind_hass -from homeassistant.util.async_ import gather_with_concurrency +from homeassistant.util.async_ import gather_with_limited_concurrency FLOW_INIT_LIMIT = 2 DISCOVERY_FLOW_DISPATCHER = "discovery_flow_dispatcher" @@ -93,7 +93,7 @@ class FlowDispatcher: for flow_key, flows in pending_flows.items() for flow_values in flows ] - await gather_with_concurrency( + await gather_with_limited_concurrency( FLOW_INIT_LIMIT, *[init_coro for init_coro in init_coros if init_coro is not None], ) diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index bc4cf68bb81..bcc7be62265 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -171,7 +171,7 @@ def protect_loop(func: Callable[_P, _R], strict: bool = True) -> Callable[_P, _R return protected_loop_func -async def gather_with_concurrency( +async def gather_with_limited_concurrency( limit: int, *tasks: Any, return_exceptions: bool = False ) -> Any: """Wrap asyncio.gather to limit the number of concurrent tasks. diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 4945e95d2d7..60f86ee7af4 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -183,8 +183,8 @@ async def test_protect_loop_debugger_sleep(caplog: pytest.LogCaptureFixture) -> assert "Detected blocking call inside the event loop" not in caplog.text -async def test_gather_with_concurrency() -> None: - """Test gather_with_concurrency limits the number of running tasks.""" +async def test_gather_with_limited_concurrency() -> None: + """Test gather_with_limited_concurrency limits the number of running tasks.""" runs = 0 now_time = time.time() @@ -198,7 +198,7 @@ async def test_gather_with_concurrency() -> None: await asyncio.sleep(0.1) return runs - results = await hasync.gather_with_concurrency( + results = await hasync.gather_with_limited_concurrency( 2, *(_increment_runs_if_in_time() for i in range(4)) ) From 2d833fd6eaa465fcf8a8c08e7ceba2f4980068ba Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Thu, 19 Oct 2023 15:49:43 +0200 Subject: [PATCH 555/968] Add more diagnostic sensors to iRobot (#84995) Co-authored-by: 930913 <3722064+930913@users.noreply.github.com> Co-authored-by: Xitee <59659167+Xitee1@users.noreply.github.com> Co-authored-by: J. Nick Koston Co-authored-by: Joost Lekkerkerker --- .../components/roomba/irobot_base.py | 12 ++ homeassistant/components/roomba/sensor.py | 177 +++++++++++++++++- 2 files changed, 186 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index a48b3638608..561effeb6c5 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -105,6 +105,18 @@ class IRobotEntity(Entity): """Return the battery level of the vacuum cleaner.""" return self.vacuum_state.get("batPct") + @property + def _run_stats(self): + return self.vacuum_state.get("bbrun") + + @property + def _mission_stats(self): + return self.vacuum_state.get("bbmssn") + + @property + def _battery_stats(self): + return self.vacuum_state.get("bbchg3") + @property def _robot_state(self): """Return the state of the vacuum cleaner.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index dd74a023ff1..7c34169eb85 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -1,7 +1,11 @@ """Sensor for checking the battery level of Roomba.""" -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -18,8 +22,31 @@ async def async_setup_entry( domain_data = hass.data[DOMAIN][config_entry.entry_id] roomba = domain_data[ROOMBA_SESSION] blid = domain_data[BLID] + roomba_vac = RoombaBattery(roomba, blid) - async_add_entities([roomba_vac], True) + roomba_battery_cycles = BatteryCycles(roomba, blid) + roomba_cleaning_time = CleaningTime(roomba, blid) + roomba_average_mission_time = AverageMissionTime(roomba, blid) + roomba_total_missions = MissionSensor(roomba, blid, "total", "nMssn") + roomba_success_missions = MissionSensor(roomba, blid, "successful", "nMssnOk") + roomba_canceled_missions = MissionSensor(roomba, blid, "canceled", "nMssnC") + roomba_failed_missions = MissionSensor(roomba, blid, "failed", "nMssnF") + roomba_scrubs_count = ScrubsCount(roomba, blid) + + async_add_entities( + [ + roomba_vac, + roomba_battery_cycles, + roomba_cleaning_time, + roomba_average_mission_time, + roomba_total_missions, + roomba_success_missions, + roomba_canceled_missions, + roomba_failed_missions, + roomba_scrubs_count, + ], + True, + ) class RoombaBattery(IRobotEntity, SensorEntity): @@ -38,3 +65,147 @@ class RoombaBattery(IRobotEntity, SensorEntity): def native_value(self): """Return the state of the sensor.""" return self._battery_level + + +class BatteryCycles(IRobotEntity, SensorEntity): + """Class to hold Roomba Sensor basic info.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:counter" + + @property + def name(self): + """Return the name of the sensor.""" + return "Battery cycles" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"battery_cycles_{self._blid}" + + @property + def state_class(self): + """Return the state class of this entity, from STATE_CLASSES, if any.""" + return SensorStateClass.MEASUREMENT + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._battery_stats.get("nLithChrg") or self._battery_stats.get( + "nNimhChrg" + ) + + +class CleaningTime(IRobotEntity, SensorEntity): + """Class to hold Roomba Sensor basic info.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:clock" + _attr_native_unit_of_measurement = UnitOfTime.HOURS + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} cleaning time total" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"total_cleaning_time_{self._blid}" + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._run_stats.get("hr") + + +class AverageMissionTime(IRobotEntity, SensorEntity): + """Class to hold Roomba Sensor basic info.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:clock" + + @property + def name(self): + """Return the name of the sensor.""" + return "Average mission time" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"average_mission_time_{self._blid}" + + @property + def native_unit_of_measurement(self): + """Return the unit_of_measurement of the device.""" + return UnitOfTime.MINUTES + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._mission_stats.get("aMssnM") + + +class MissionSensor(IRobotEntity, SensorEntity): + """Class to hold the Roomba missions info.""" + + def __init__(self, roomba, blid, mission_type, mission_value_string): + """Initialise iRobot sensor with mission details.""" + super().__init__(roomba, blid) + self._mission_type = mission_type + self._mission_value_string = mission_value_string + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:counter" + + @property + def name(self): + """Return the name of the sensor.""" + return f"Missions {self._mission_type}" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"{self._mission_type}_missions_{self._blid}" + + @property + def state_class(self): + """Return the state class of this entity, from STATE_CLASSES, if any.""" + return SensorStateClass.MEASUREMENT + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._mission_stats.get(self._mission_value_string) + + +class ScrubsCount(IRobotEntity, SensorEntity): + """Class to hold Roomba Sensor basic info.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_icon = "mdi:counter" + + @property + def name(self): + """Return the name of the sensor.""" + return "Scrubs count" + + @property + def unique_id(self): + """Return the ID of this sensor.""" + return f"scrubs_count_{self._blid}" + + @property + def state_class(self): + """Return the state class of this entity, from STATE_CLASSES, if any.""" + return SensorStateClass.MEASUREMENT + + @property + def entity_registry_enabled_default(self): + """Disable sensor by default.""" + return False + + @property + def native_value(self): + """Return the state of the sensor.""" + return self._run_stats.get("nScrubs") From dae742fba02c852bee02129e4a45d424a32330a1 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Thu, 19 Oct 2023 16:40:08 +0200 Subject: [PATCH 556/968] Use snapshots in weather tests (#102297) --- .../template/snapshots/test_weather.ambr | 101 +++++++++++++++ tests/components/template/test_weather.py | 118 ++++-------------- .../weather/snapshots/test_init.ambr | 41 ++++++ tests/components/weather/test_init.py | 23 +--- 4 files changed, 175 insertions(+), 108 deletions(-) create mode 100644 tests/components/template/snapshots/test_weather.ambr create mode 100644 tests/components/weather/snapshots/test_init.ambr diff --git a/tests/components/template/snapshots/test_weather.ambr b/tests/components/template/snapshots/test_weather.ambr new file mode 100644 index 00000000000..72af2ab1637 --- /dev/null +++ b/tests/components/template/snapshots/test_weather.ambr @@ -0,0 +1,101 @@ +# serializer version: 1 +# name: test_forecasts[config0-1-weather] + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'fog', + 'datetime': '2023-02-17T14:00:00+00:00', + 'is_daytime': True, + 'temperature': 14.2, + }), + ]), + }) +# --- +# name: test_forecasts[config0-1-weather].3 + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-02-17T14:00:00+00:00', + 'temperature': 16.9, + }), + ]), + }) +# --- +# name: test_restore_weather_save_state + dict({ + 'last_apparent_temperature': None, + 'last_cloud_coverage': None, + 'last_dew_point': None, + 'last_humidity': '25.0', + 'last_ozone': None, + 'last_pressure': None, + 'last_temperature': '15.0', + 'last_visibility': None, + 'last_wind_bearing': None, + 'last_wind_gust_speed': None, + 'last_wind_speed': None, + }) +# --- +# name: test_trigger_weather_services[config0-1-template] + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template].1 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- +# name: test_trigger_weather_services[config0-1-template].2 + dict({ + 'forecast': list([ + dict({ + 'condition': 'sunny', + 'datetime': '2023-10-19T06:50:05-07:00', + 'is_daytime': True, + 'precipitation': 20.0, + 'temperature': 20.0, + 'templow': 15.0, + }), + ]), + }) +# --- diff --git a/tests/components/template/test_weather.py b/tests/components/template/test_weather.py index 7ca3d11b099..524f9c41aeb 100644 --- a/tests/components/template/test_weather.py +++ b/tests/components/template/test_weather.py @@ -2,6 +2,7 @@ from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( ATTR_FORECAST, @@ -112,7 +113,9 @@ async def test_template_state_text(hass: HomeAssistant, start_ha) -> None: }, ], ) -async def test_forecasts(hass: HomeAssistant, start_ha) -> None: +async def test_forecasts( + hass: HomeAssistant, start_ha, snapshot: SnapshotAssertion +) -> None: """Test forecast service.""" for attr, _v_attr, value in [ ("sensor.temperature", ATTR_WEATHER_TEMPERATURE, 22.3), @@ -163,15 +166,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - } - ] - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -179,15 +174,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - } - ] - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -195,16 +182,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "fog", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 14.2, - "is_daytime": True, - } - ] - } + assert response == snapshot hass.states.async_set( "weather.forecast", @@ -231,15 +209,7 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "condition": "cloudy", - "datetime": "2023-02-17T14:00:00+00:00", - "temperature": 16.9, - } - ] - } + assert response == snapshot @pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)]) @@ -263,7 +233,9 @@ async def test_forecasts(hass: HomeAssistant, start_ha) -> None: ], ) async def test_forecast_invalid( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid forecasts.""" for attr, _v_attr, value in [ @@ -336,7 +308,9 @@ async def test_forecast_invalid( ], ) async def test_forecast_invalid_is_daytime_missing_in_twice_daily( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test forecast service invalid when is_daytime missing in twice_daily forecast.""" for attr, _v_attr, value in [ @@ -395,7 +369,9 @@ async def test_forecast_invalid_is_daytime_missing_in_twice_daily( ], ) async def test_forecast_invalid_datetime_missing( - hass: HomeAssistant, start_ha, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + start_ha, + caplog: pytest.LogCaptureFixture, ) -> None: """Test forecast service invalid when datetime missing.""" for attr, _v_attr, value in [ @@ -712,8 +688,12 @@ async def test_trigger_action( }, ], ) +@pytest.mark.freeze_time("2023-10-19 13:50:05") async def test_trigger_weather_services( - hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry + hass: HomeAssistant, + start_ha, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test trigger weather entity with services.""" state = hass.states.get("weather.test") @@ -784,17 +764,7 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - } - ], - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, @@ -806,17 +776,7 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - } - ], - } + assert response == snapshot response = await hass.services.async_call( WEATHER_DOMAIN, @@ -828,23 +788,11 @@ async def test_trigger_weather_services( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "datetime": now, - "condition": "sunny", - "precipitation": 20.0, - "temperature": 20.0, - "templow": 15.0, - "is_daytime": True, - } - ], - } + assert response == snapshot async def test_restore_weather_save_state( - hass: HomeAssistant, - hass_storage: dict[str, Any], + hass: HomeAssistant, hass_storage: dict[str, Any], snapshot: SnapshotAssertion ) -> None: """Test Restore saved state for Weather trigger template.""" assert await async_setup_component( @@ -881,19 +829,7 @@ async def test_restore_weather_save_state( state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] assert state["entity_id"] == entity.entity_id extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] - assert extra_data == { - "last_apparent_temperature": None, - "last_cloud_coverage": None, - "last_dew_point": None, - "last_humidity": "25.0", - "last_ozone": None, - "last_pressure": None, - "last_temperature": "15.0", - "last_visibility": None, - "last_wind_bearing": None, - "last_wind_gust_speed": None, - "last_wind_speed": None, - } + assert extra_data == snapshot SAVED_ATTRIBUTES_1 = { diff --git a/tests/components/weather/snapshots/test_init.ambr b/tests/components/weather/snapshots/test_init.ambr new file mode 100644 index 00000000000..03a2d46c80f --- /dev/null +++ b/tests/components/weather/snapshots/test_init.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_get_forecast[daily-1] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_get_forecast[hourly-2] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- +# name: test_get_forecast[twice_daily-4] + dict({ + 'forecast': list([ + dict({ + 'cloud_coverage': None, + 'is_daytime': True, + 'temperature': 38.0, + 'templow': 38.0, + 'uv_index': None, + 'wind_bearing': None, + }), + ]), + }) +# --- diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 231f08c7cc1..f17edb16f07 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Any import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, @@ -854,14 +855,13 @@ async def test_forecast_twice_daily_missing_is_daytime( @pytest.mark.parametrize( - ("forecast_type", "supported_features", "extra"), + ("forecast_type", "supported_features"), [ - ("daily", WeatherEntityFeature.FORECAST_DAILY, {}), - ("hourly", WeatherEntityFeature.FORECAST_HOURLY, {}), + ("daily", WeatherEntityFeature.FORECAST_DAILY), + ("hourly", WeatherEntityFeature.FORECAST_HOURLY), ( "twice_daily", WeatherEntityFeature.FORECAST_TWICE_DAILY, - {"is_daytime": True}, ), ], ) @@ -870,7 +870,7 @@ async def test_get_forecast( enable_custom_integrations: None, forecast_type: str, supported_features: int, - extra: dict[str, Any], + snapshot: SnapshotAssertion, ) -> None: """Test get forecast service.""" @@ -891,18 +891,7 @@ async def test_get_forecast( blocking=True, return_response=True, ) - assert response == { - "forecast": [ - { - "cloud_coverage": None, - "temperature": 38.0, - "templow": 38.0, - "uv_index": None, - "wind_bearing": None, - } - | extra - ], - } + assert response == snapshot async def test_get_forecast_no_forecast( From 90687e979458a6e43a5568e58dffd5af19f87458 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 19 Oct 2023 17:34:17 +0200 Subject: [PATCH 557/968] Standardize zha attribute member name (#102182) * Correct missed translation * Standardize on _attribute for zha --- homeassistant/components/zha/binary_sensor.py | 38 ++++---- homeassistant/components/zha/number.py | 84 +++++++++-------- homeassistant/components/zha/select.py | 10 +- homeassistant/components/zha/sensor.py | 92 +++++++++---------- homeassistant/components/zha/switch.py | 82 +++++++++-------- 5 files changed, 153 insertions(+), 153 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 0118625293a..9b057a3cbc3 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -72,7 +72,7 @@ async def async_setup_entry( class BinarySensor(ZhaEntity, BinarySensorEntity): """ZHA BinarySensor.""" - SENSOR_ATTR: str | None = None + _attribute_name: str def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): """Initialize the ZHA binary sensor.""" @@ -89,7 +89,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return True if the switch is on based on the state machine.""" - raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) + raw_state = self._cluster_handler.cluster.get(self._attribute_name) if raw_state is None: return False return self.parse(raw_state) @@ -109,7 +109,7 @@ class BinarySensor(ZhaEntity, BinarySensorEntity): class Accelerometer(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "acceleration" + _attribute_name = "acceleration" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.MOVING _attr_translation_key: str = "accelerometer" @@ -118,7 +118,7 @@ class Accelerometer(BinarySensor): class Occupancy(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "occupancy" + _attribute_name = "occupancy" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OCCUPANCY @@ -133,7 +133,7 @@ class HueOccupancy(Occupancy): class Opening(BinarySensor): """ZHA OnOff BinarySensor.""" - SENSOR_ATTR = "on_off" + _attribute_name = "on_off" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.OPENING # Client/out cluster attributes aren't stored in the zigpy database, but are properly stored in the runtime cache. @@ -142,7 +142,7 @@ class Opening(BinarySensor): def async_restore_last_state(self, last_state): """Restore previous state to zigpy cache.""" self._cluster_handler.cluster.update_attribute( - OnOff.attributes_by_name[self.SENSOR_ATTR].id, + OnOff.attributes_by_name[self._attribute_name].id, t.Bool.true if last_state.state == STATE_ON else t.Bool.false, ) @@ -151,7 +151,7 @@ class Opening(BinarySensor): class BinaryInput(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "present_value" + _attribute_name = "present_value" _attr_translation_key: str = "binary_input" @@ -177,7 +177,7 @@ class Motion(Opening): class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" - SENSOR_ATTR = "zone_status" + _attribute_name = "zone_status" @property def translation_key(self) -> str | None: @@ -225,7 +225,7 @@ class IASZone(BinarySensor): migrated_state = IasZone.ZoneStatus(0) self._cluster_handler.cluster.update_attribute( - IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state + IasZone.attributes_by_name[self._attribute_name].id, migrated_state ) @@ -233,7 +233,7 @@ class IASZone(BinarySensor): class SinopeLeakStatus(BinarySensor): """Sinope water leak sensor.""" - SENSOR_ATTR = "leak_status" + _attribute_name = "leak_status" _attr_device_class = BinarySensorDeviceClass.MOISTURE @@ -246,7 +246,7 @@ class SinopeLeakStatus(BinarySensor): class FrostLock(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "frost_lock" + _attribute_name = "frost_lock" _unique_id_suffix = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK _attr_translation_key: str = "frost_lock" @@ -256,7 +256,7 @@ class FrostLock(BinarySensor): class ReplaceFilter(BinarySensor): """ZHA BinarySensor.""" - SENSOR_ATTR = "replace_filter" + _attribute_name = "replace_filter" _unique_id_suffix = "replace_filter" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC @@ -267,7 +267,7 @@ class ReplaceFilter(BinarySensor): class AqaraPetFeederErrorDetected(BinarySensor): """ZHA aqara pet feeder error detected binary sensor.""" - SENSOR_ATTR = "error_detected" + _attribute_name = "error_detected" _unique_id_suffix = "error_detected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM @@ -279,7 +279,7 @@ class AqaraPetFeederErrorDetected(BinarySensor): class XiaomiPlugConsumerConnected(BinarySensor): """ZHA Xiaomi plug consumer connected binary sensor.""" - SENSOR_ATTR = "consumer_connected" + _attribute_name = "consumer_connected" _unique_id_suffix = "consumer_connected" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PLUG _attr_translation_key: str = "consumer_connected" @@ -289,7 +289,7 @@ class XiaomiPlugConsumerConnected(BinarySensor): class AqaraThermostatWindowOpen(BinarySensor): """ZHA Aqara thermostat window open binary sensor.""" - SENSOR_ATTR = "window_open" + _attribute_name = "window_open" _unique_id_suffix = "window_open" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.WINDOW @@ -298,7 +298,7 @@ class AqaraThermostatWindowOpen(BinarySensor): class AqaraThermostatValveAlarm(BinarySensor): """ZHA Aqara thermostat valve alarm binary sensor.""" - SENSOR_ATTR = "valve_alarm" + _attribute_name = "valve_alarm" _unique_id_suffix = "valve_alarm" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM _attr_translation_key: str = "valve_alarm" @@ -310,7 +310,7 @@ class AqaraThermostatValveAlarm(BinarySensor): class AqaraThermostatCalibrated(BinarySensor): """ZHA Aqara thermostat calibrated binary sensor.""" - SENSOR_ATTR = "calibrated" + _attribute_name = "calibrated" _unique_id_suffix = "calibrated" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_translation_key: str = "calibrated" @@ -322,7 +322,7 @@ class AqaraThermostatCalibrated(BinarySensor): class AqaraThermostatExternalSensor(BinarySensor): """ZHA Aqara thermostat external sensor binary sensor.""" - SENSOR_ATTR = "sensor" + _attribute_name = "sensor" _unique_id_suffix = "sensor" _attr_entity_category: EntityCategory = EntityCategory.DIAGNOSTIC _attr_translation_key: str = "external_sensor" @@ -332,7 +332,7 @@ class AqaraThermostatExternalSensor(BinarySensor): class AqaraLinkageAlarmState(BinarySensor): """ZHA Aqara linkage alarm state binary sensor.""" - SENSOR_ATTR = "linkage_alarm_state" + _attribute_name = "linkage_alarm_state" _unique_id_suffix = "linkage_alarm_state" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.SMOKE _attr_translation_key: str = "linkage_alarm_state" diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index ad5f1debcd8..ae2f9e0b758 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -381,7 +381,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_step: float = 1.0 _attr_multiplier: float = 1 - _zcl_attribute: str + _attribute_name: str @classmethod def create_entity( @@ -397,13 +397,13 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes - or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._zcl_attribute) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._zcl_attribute, + cls._attribute_name, cls.__name__, ) return None @@ -425,14 +425,14 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): def native_value(self) -> float: """Return the current value.""" return ( - self._cluster_handler.cluster.get(self._zcl_attribute) + self._cluster_handler.cluster.get(self._attribute_name) * self._attr_multiplier ) async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" await self._cluster_handler.write_attributes_safe( - {self._zcl_attribute: int(value / self._attr_multiplier)} + {self._attribute_name: int(value / self._attr_multiplier)} ) self.async_write_ha_state() @@ -442,7 +442,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): _LOGGER.debug("polling current state") if self._cluster_handler: value = await self._cluster_handler.get_attribute_value( - self._zcl_attribute, from_cache=False + self._attribute_name, from_cache=False ) _LOGGER.debug("read value=%s", value) @@ -458,7 +458,7 @@ class AqaraMotionDetectionInterval(ZHANumberConfigurationEntity): _unique_id_suffix = "detection_interval" _attr_native_min_value: float = 2 _attr_native_max_value: float = 65535 - _zcl_attribute: str = "detection_interval" + _attribute_name = "detection_interval" _attr_translation_key: str = "detection_interval" @@ -470,7 +470,7 @@ class OnOffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "on_off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFF - _zcl_attribute: str = "on_off_transition_time" + _attribute_name = "on_off_transition_time" _attr_translation_key: str = "on_off_transition_time" @@ -482,7 +482,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "on_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF - _zcl_attribute: str = "on_level" + _attribute_name = "on_level" _attr_translation_key: str = "on_level" @@ -494,7 +494,7 @@ class OnTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "on_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE - _zcl_attribute: str = "on_transition_time" + _attribute_name = "on_transition_time" _attr_translation_key: str = "on_transition_time" @@ -506,7 +506,7 @@ class OffTransitionTimeConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "off_transition_time" _attr_native_min_value: float = 0x0000 _attr_native_max_value: float = 0xFFFE - _zcl_attribute: str = "off_transition_time" + _attribute_name = "off_transition_time" _attr_translation_key: str = "off_transition_time" @@ -518,7 +518,7 @@ class DefaultMoveRateConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "default_move_rate" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFE - _zcl_attribute: str = "default_move_rate" + _attribute_name = "default_move_rate" _attr_translation_key: str = "default_move_rate" @@ -530,7 +530,7 @@ class StartUpCurrentLevelConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "start_up_current_level" _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFF - _zcl_attribute: str = "start_up_current_level" + _attribute_name = "start_up_current_level" _attr_translation_key: str = "start_up_current_level" @@ -542,7 +542,7 @@ class StartUpColorTemperatureConfigurationEntity(ZHANumberConfigurationEntity): _unique_id_suffix = "start_up_color_temperature" _attr_native_min_value: float = 153 _attr_native_max_value: float = 500 - _zcl_attribute: str = "start_up_color_temperature" + _attribute_name = "start_up_color_temperature" _attr_translation_key: str = "start_up_color_temperature" def __init__( @@ -575,7 +575,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0x257 _attr_native_unit_of_measurement: str | None = UNITS[72] - _zcl_attribute: str = "timer_duration" + _attribute_name = "timer_duration" _attr_translation_key: str = "timer_duration" @@ -590,7 +590,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity): _attr_native_min_value: float = 0x00 _attr_native_max_value: float = 0xFFFFFFFF _attr_native_unit_of_measurement: str | None = UNITS[72] - _zcl_attribute: str = "filter_life_time" + _attribute_name = "filter_life_time" _attr_translation_key: str = "filter_life_time" @@ -606,7 +606,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity): _unique_id_suffix = "transmit_power" _attr_native_min_value: float = -20 _attr_native_max_value: float = 20 - _zcl_attribute: str = "transmit_power" + _attribute_name = "transmit_power" _attr_translation_key: str = "transmit_power" @@ -620,7 +620,7 @@ class InovelliRemoteDimmingUpSpeed(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 126 - _zcl_attribute: str = "dimming_speed_up_remote" + _attribute_name = "dimming_speed_up_remote" _attr_translation_key: str = "dimming_speed_up_remote" @@ -634,7 +634,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 9 - _zcl_attribute: str = "button_delay" + _attribute_name = "button_delay" _attr_translation_key: str = "button_delay" @@ -648,7 +648,7 @@ class InovelliLocalDimmingUpSpeed(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_up_local" + _attribute_name = "dimming_speed_up_local" _attr_translation_key: str = "dimming_speed_up_local" @@ -662,11 +662,9 @@ class InovelliLocalRampRateOffToOn(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_off_to_on_local" + _attribute_name = "ramp_rate_off_to_on_local" _attr_translation_key: str = "ramp_rate_off_to_on_local" - _attr_name: str = "Local ramp rate off to on" - @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) # pylint: disable-next=hass-invalid-inheritance # needs fixing @@ -678,7 +676,7 @@ class InovelliRemoteDimmingSpeedOffToOn(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_off_to_on_remote" + _attribute_name = "ramp_rate_off_to_on_remote" _attr_translation_key: str = "ramp_rate_off_to_on_remote" @@ -692,7 +690,7 @@ class InovelliRemoteDimmingDownSpeed(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_down_remote" + _attribute_name = "dimming_speed_down_remote" _attr_translation_key: str = "dimming_speed_down_remote" @@ -706,7 +704,7 @@ class InovelliLocalDimmingDownSpeed(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "dimming_speed_down_local" + _attribute_name = "dimming_speed_down_local" _attr_translation_key: str = "dimming_speed_down_local" @@ -720,7 +718,7 @@ class InovelliLocalRampRateOnToOff(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_on_to_off_local" + _attribute_name = "ramp_rate_on_to_off_local" _attr_translation_key: str = "ramp_rate_on_to_off_local" @@ -734,7 +732,7 @@ class InovelliRemoteDimmingSpeedOnToOff(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[3] _attr_native_min_value: float = 0 _attr_native_max_value: float = 127 - _zcl_attribute: str = "ramp_rate_on_to_off_remote" + _attribute_name = "ramp_rate_on_to_off_remote" _attr_translation_key: str = "ramp_rate_on_to_off_remote" @@ -748,7 +746,7 @@ class InovelliMinimumLoadDimmingLevel(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 1 _attr_native_max_value: float = 254 - _zcl_attribute: str = "minimum_level" + _attribute_name = "minimum_level" _attr_translation_key: str = "minimum_level" @@ -762,7 +760,7 @@ class InovelliMaximumLoadDimmingLevel(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 _attr_native_max_value: float = 255 - _zcl_attribute: str = "maximum_level" + _attribute_name = "maximum_level" _attr_translation_key: str = "maximum_level" @@ -776,7 +774,7 @@ class InovelliAutoShutoffTimer(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 _attr_native_max_value: float = 32767 - _zcl_attribute: str = "auto_off_timer" + _attribute_name = "auto_off_timer" _attr_translation_key: str = "auto_off_timer" @@ -790,7 +788,7 @@ class InovelliLoadLevelIndicatorTimeout(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[14] _attr_native_min_value: float = 0 _attr_native_max_value: float = 11 - _zcl_attribute: str = "load_level_indicator_timeout" + _attribute_name = "load_level_indicator_timeout" _attr_translation_key: str = "load_level_indicator_timeout" @@ -804,7 +802,7 @@ class InovelliDefaultAllLEDOnColor(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 - _zcl_attribute: str = "led_color_when_on" + _attribute_name = "led_color_when_on" _attr_translation_key: str = "led_color_when_on" @@ -818,7 +816,7 @@ class InovelliDefaultAllLEDOffColor(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[15] _attr_native_min_value: float = 0 _attr_native_max_value: float = 255 - _zcl_attribute: str = "led_color_when_off" + _attribute_name = "led_color_when_off" _attr_translation_key: str = "led_color_when_off" @@ -832,7 +830,7 @@ class InovelliDefaultAllLEDOnIntensity(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 - _zcl_attribute: str = "led_intensity_when_on" + _attribute_name = "led_intensity_when_on" _attr_translation_key: str = "led_intensity_when_on" @@ -846,7 +844,7 @@ class InovelliDefaultAllLEDOffIntensity(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 100 - _zcl_attribute: str = "led_intensity_when_off" + _attribute_name = "led_intensity_when_off" _attr_translation_key: str = "led_intensity_when_off" @@ -860,7 +858,7 @@ class InovelliDoubleTapUpLevel(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 2 _attr_native_max_value: float = 254 - _zcl_attribute: str = "double_tap_up_level" + _attribute_name = "double_tap_up_level" _attr_translation_key: str = "double_tap_up_level" @@ -874,7 +872,7 @@ class InovelliDoubleTapDownLevel(ZHANumberConfigurationEntity): _attr_icon: str = ICONS[16] _attr_native_min_value: float = 0 _attr_native_max_value: float = 254 - _zcl_attribute: str = "double_tap_down_level" + _attribute_name = "double_tap_down_level" _attr_translation_key: str = "double_tap_down_level" @@ -889,7 +887,7 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 10 - _zcl_attribute: str = "serving_size" + _attribute_name = "serving_size" _attr_translation_key: str = "serving_size" _attr_mode: NumberMode = NumberMode.BOX @@ -907,7 +905,7 @@ class AqaraPetFeederPortionWeight(ZHANumberConfigurationEntity): _attr_entity_category = EntityCategory.CONFIG _attr_native_min_value: float = 1 _attr_native_max_value: float = 100 - _zcl_attribute: str = "portion_weight" + _attribute_name = "portion_weight" _attr_translation_key: str = "portion_weight" _attr_mode: NumberMode = NumberMode.BOX @@ -927,7 +925,7 @@ class AqaraThermostatAwayTemp(ZHANumberConfigurationEntity): _attr_native_min_value: float = 5 _attr_native_max_value: float = 30 _attr_multiplier: float = 0.01 - _zcl_attribute: str = "away_preset_temperature" + _attribute_name = "away_preset_temperature" _attr_translation_key: str = "away_preset_temperature" _attr_mode: NumberMode = NumberMode.SLIDER diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 980f589819c..b98a0c3f07b 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -67,7 +67,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG - _attribute: str + _attribute_name: str _enum: type[Enum] def __init__( @@ -78,7 +78,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): **kwargs: Any, ) -> None: """Init this select entity.""" - self._attribute = self._enum.__name__ + self._attribute_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._cluster_handler: ClusterHandler = cluster_handlers[0] super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) @@ -86,14 +86,14 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._cluster_handler.data_cache.get(self._attribute) + option = self._cluster_handler.data_cache.get(self._attribute_name) if option is None: return None return option.name.replace("_", " ") async def async_select_option(self, option: str) -> None: """Change the selected option.""" - self._cluster_handler.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute_name] = self._enum[ option.replace(" ", "_") ] self.async_write_ha_state() @@ -102,7 +102,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): def async_restore_last_state(self, last_state) -> None: """Restore previous state.""" if last_state.state and last_state.state != STATE_UNKNOWN: - self._cluster_handler.data_cache[self._attribute] = self._enum[ + self._cluster_handler.data_cache[self._attribute_name] = self._enum[ last_state.state.replace(" ", "_") ] diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 8ddedebfa79..4fe96109c46 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -118,7 +118,7 @@ async def async_setup_entry( class Sensor(ZhaEntity, SensorEntity): """Base ZHA sensor.""" - SENSOR_ATTR: int | str | None = None + _attribute_name: int | str | None = None _decimals: int = 1 _divisor: int = 1 _multiplier: int | float = 1 @@ -148,8 +148,8 @@ class Sensor(ZhaEntity, SensorEntity): """ cluster_handler = cluster_handlers[0] if ( - cls.SENSOR_ATTR in cluster_handler.cluster.unsupported_attributes - or cls.SENSOR_ATTR not in cluster_handler.cluster.attributes_by_name + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name ): return None @@ -165,8 +165,8 @@ class Sensor(ZhaEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state of the entity.""" - assert self.SENSOR_ATTR is not None - raw_state = self._cluster_handler.cluster.get(self.SENSOR_ATTR) + assert self._attribute_name is not None + raw_state = self._cluster_handler.cluster.get(self._attribute_name) if raw_state is None: return None return self.formatter(raw_state) @@ -194,7 +194,7 @@ class Sensor(ZhaEntity, SensorEntity): class AnalogInput(Sensor): """Sensor that displays analog input values.""" - SENSOR_ATTR = "present_value" + _attribute_name = "present_value" _attr_translation_key: str = "analog_input" @@ -203,7 +203,7 @@ class AnalogInput(Sensor): class Battery(Sensor): """Battery sensor of power configuration cluster.""" - SENSOR_ATTR = "battery_percentage_remaining" + _attribute_name = "battery_percentage_remaining" _attr_device_class: SensorDeviceClass = SensorDeviceClass.BATTERY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_entity_category = EntityCategory.DIAGNOSTIC @@ -261,7 +261,7 @@ class Battery(Sensor): class ElectricalMeasurement(Sensor): """Active power measurement.""" - SENSOR_ATTR = "active_power" + _attribute_name = "active_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement: str = UnitOfPower.WATT @@ -274,7 +274,7 @@ class ElectricalMeasurement(Sensor): if self._cluster_handler.measurement_type is not None: attrs["measurement_type"] = self._cluster_handler.measurement_type - max_attr_name = f"{self.SENSOR_ATTR}_max" + max_attr_name = f"{self._attribute_name}_max" try: max_v = self._cluster_handler.cluster.get(max_attr_name) @@ -320,7 +320,7 @@ class PolledElectricalMeasurement(ElectricalMeasurement): class ElectricalMeasurementApparentPower(ElectricalMeasurement): """Apparent power measurement.""" - SENSOR_ATTR = "apparent_power" + _attribute_name = "apparent_power" _unique_id_suffix = "apparent_power" _attr_device_class: SensorDeviceClass = SensorDeviceClass.APPARENT_POWER _attr_native_unit_of_measurement = UnitOfApparentPower.VOLT_AMPERE @@ -332,7 +332,7 @@ class ElectricalMeasurementApparentPower(ElectricalMeasurement): class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): """RMS current measurement.""" - SENSOR_ATTR = "rms_current" + _attribute_name = "rms_current" _unique_id_suffix = "rms_current" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CURRENT _attr_native_unit_of_measurement = UnitOfElectricCurrent.AMPERE @@ -344,7 +344,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement): class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): """RMS Voltage measurement.""" - SENSOR_ATTR = "rms_voltage" + _attribute_name = "rms_voltage" _unique_id_suffix = "rms_voltage" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLTAGE _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT @@ -356,7 +356,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement): class ElectricalMeasurementFrequency(ElectricalMeasurement): """Frequency measurement.""" - SENSOR_ATTR = "ac_frequency" + _attribute_name = "ac_frequency" _unique_id_suffix = "ac_frequency" _attr_device_class: SensorDeviceClass = SensorDeviceClass.FREQUENCY _attr_translation_key: str = "ac_frequency" @@ -369,7 +369,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement): class ElectricalMeasurementPowerFactor(ElectricalMeasurement): """Frequency measurement.""" - SENSOR_ATTR = "power_factor" + _attribute_name = "power_factor" _unique_id_suffix = "power_factor" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER_FACTOR _attr_native_unit_of_measurement = PERCENTAGE @@ -387,7 +387,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement): class Humidity(Sensor): """Humidity sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 @@ -399,7 +399,7 @@ class Humidity(Sensor): class SoilMoisture(Sensor): """Soil Moisture sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "soil_moisture" @@ -412,7 +412,7 @@ class SoilMoisture(Sensor): class LeafWetness(Sensor): """Leaf Wetness sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.HUMIDITY _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "leaf_wetness" @@ -425,7 +425,7 @@ class LeafWetness(Sensor): class Illuminance(Sensor): """Illuminance Sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ILLUMINANCE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = LIGHT_LUX @@ -443,7 +443,7 @@ class Illuminance(Sensor): class SmartEnergyMetering(Sensor): """Metering sensor.""" - SENSOR_ATTR: int | str = "instantaneous_demand" + _attribute_name = "instantaneous_demand" _attr_device_class: SensorDeviceClass = SensorDeviceClass.POWER _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "instantaneous_demand" @@ -497,7 +497,7 @@ class SmartEnergyMetering(Sensor): class SmartEnergySummation(SmartEnergyMetering): """Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_summ_delivered" + _attribute_name = "current_summ_delivered" _unique_id_suffix = "summation_delivered" _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENERGY _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING @@ -557,7 +557,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): class Tier1SmartEnergySummation(PolledSmartEnergySummation): """Tier 1 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier1_summ_delivered" + _attribute_name = "current_tier1_summ_delivered" _unique_id_suffix = "tier1_summation_delivered" _attr_translation_key: str = "tier1_summation_delivered" @@ -570,7 +570,7 @@ class Tier1SmartEnergySummation(PolledSmartEnergySummation): class Tier2SmartEnergySummation(PolledSmartEnergySummation): """Tier 2 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier2_summ_delivered" + _attribute_name = "current_tier2_summ_delivered" _unique_id_suffix = "tier2_summation_delivered" _attr_translation_key: str = "tier2_summation_delivered" @@ -583,7 +583,7 @@ class Tier2SmartEnergySummation(PolledSmartEnergySummation): class Tier3SmartEnergySummation(PolledSmartEnergySummation): """Tier 3 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier3_summ_delivered" + _attribute_name = "current_tier3_summ_delivered" _unique_id_suffix = "tier3_summation_delivered" _attr_translation_key: str = "tier3_summation_delivered" @@ -596,7 +596,7 @@ class Tier3SmartEnergySummation(PolledSmartEnergySummation): class Tier4SmartEnergySummation(PolledSmartEnergySummation): """Tier 4 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier4_summ_delivered" + _attribute_name = "current_tier4_summ_delivered" _unique_id_suffix = "tier4_summation_delivered" _attr_translation_key: str = "tier4_summation_delivered" @@ -609,7 +609,7 @@ class Tier4SmartEnergySummation(PolledSmartEnergySummation): class Tier5SmartEnergySummation(PolledSmartEnergySummation): """Tier 5 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier5_summ_delivered" + _attribute_name = "current_tier5_summ_delivered" _unique_id_suffix = "tier5_summation_delivered" _attr_translation_key: str = "tier5_summation_delivered" @@ -622,7 +622,7 @@ class Tier5SmartEnergySummation(PolledSmartEnergySummation): class Tier6SmartEnergySummation(PolledSmartEnergySummation): """Tier 6 Smart Energy Metering summation sensor.""" - SENSOR_ATTR: int | str = "current_tier6_summ_delivered" + _attribute_name = "current_tier6_summ_delivered" _unique_id_suffix = "tier6_summation_delivered" _attr_translation_key: str = "tier6_summation_delivered" @@ -632,7 +632,7 @@ class Tier6SmartEnergySummation(PolledSmartEnergySummation): class Pressure(Sensor): """Pressure sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PRESSURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 @@ -644,7 +644,7 @@ class Pressure(Sensor): class Temperature(Sensor): """Temperature Sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _divisor = 100 @@ -656,7 +656,7 @@ class Temperature(Sensor): class DeviceTemperature(Sensor): """Device Temperature Sensor.""" - SENSOR_ATTR = "current_temperature" + _attribute_name = "current_temperature" _attr_device_class: SensorDeviceClass = SensorDeviceClass.TEMPERATURE _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "device_temperature" @@ -670,7 +670,7 @@ class DeviceTemperature(Sensor): class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO2 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 @@ -683,7 +683,7 @@ class CarbonDioxideConcentration(Sensor): class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.CO _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 @@ -697,7 +697,7 @@ class CarbonMonoxideConcentration(Sensor): class VOCLevel(Sensor): """VOC Level sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 @@ -714,7 +714,7 @@ class VOCLevel(Sensor): class PPBVOCLevel(Sensor): """VOC Level sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = ( SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS ) @@ -729,7 +729,7 @@ class PPBVOCLevel(Sensor): class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _decimals = 0 @@ -742,7 +742,7 @@ class PM25(Sensor): class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" - SENSOR_ATTR = "measured_value" + _attribute_name = "measured_value" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_translation_key: str = "formaldehyde" _decimals = 0 @@ -878,7 +878,7 @@ class SinopeHVACAction(ThermostatHVACAction): class RSSISensor(Sensor): """RSSI sensor for a device.""" - SENSOR_ATTR = "rssi" + _attribute_name = "rssi" _unique_id_suffix = "rssi" _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_device_class: SensorDeviceClass | None = SensorDeviceClass.SIGNAL_STRENGTH @@ -908,7 +908,7 @@ class RSSISensor(Sensor): @property def native_value(self) -> StateType: """Return the state of the entity.""" - return getattr(self._zha_device.device, self.SENSOR_ATTR) + return getattr(self._zha_device.device, self._attribute_name) @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) @@ -916,7 +916,7 @@ class RSSISensor(Sensor): class LQISensor(RSSISensor): """LQI sensor for a device.""" - SENSOR_ATTR = "lqi" + _attribute_name = "lqi" _unique_id_suffix = "lqi" _attr_device_class = None _attr_native_unit_of_measurement = None @@ -933,7 +933,7 @@ class LQISensor(RSSISensor): class TimeLeft(Sensor): """Sensor that displays time left value.""" - SENSOR_ATTR = "timer_time_left" + _attribute_name = "timer_time_left" _unique_id_suffix = "time_left" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" @@ -946,7 +946,7 @@ class TimeLeft(Sensor): class IkeaDeviceRunTime(Sensor): """Sensor that displays device run time (in minutes).""" - SENSOR_ATTR = "device_run_time" + _attribute_name = "device_run_time" _unique_id_suffix = "device_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" @@ -960,7 +960,7 @@ class IkeaDeviceRunTime(Sensor): class IkeaFilterRunTime(Sensor): """Sensor that displays run time of the current filter (in minutes).""" - SENSOR_ATTR = "filter_run_time" + _attribute_name = "filter_run_time" _unique_id_suffix = "filter_run_time" _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" @@ -981,7 +981,7 @@ class AqaraFeedingSource(types.enum8): class AqaraPetFeederLastFeedingSource(Sensor): """Sensor that displays the last feeding source of pet feeder.""" - SENSOR_ATTR = "last_feeding_source" + _attribute_name = "last_feeding_source" _unique_id_suffix = "last_feeding_source" _attr_translation_key: str = "last_feeding_source" _attr_icon = "mdi:devices" @@ -996,7 +996,7 @@ class AqaraPetFeederLastFeedingSource(Sensor): class AqaraPetFeederLastFeedingSize(Sensor): """Sensor that displays the last feeding size of the pet feeder.""" - SENSOR_ATTR = "last_feeding_size" + _attribute_name = "last_feeding_size" _unique_id_suffix = "last_feeding_size" _attr_translation_key: str = "last_feeding_size" _attr_icon: str = "mdi:counter" @@ -1007,7 +1007,7 @@ class AqaraPetFeederLastFeedingSize(Sensor): class AqaraPetFeederPortionsDispensed(Sensor): """Sensor that displays the number of portions dispensed by the pet feeder.""" - SENSOR_ATTR = "portions_dispensed" + _attribute_name = "portions_dispensed" _unique_id_suffix = "portions_dispensed" _attr_translation_key: str = "portions_dispensed_today" _attr_state_class: SensorStateClass = SensorStateClass.TOTAL_INCREASING @@ -1019,7 +1019,7 @@ class AqaraPetFeederPortionsDispensed(Sensor): class AqaraPetFeederWeightDispensed(Sensor): """Sensor that displays the weight dispensed by the pet feeder.""" - SENSOR_ATTR = "weight_dispensed" + _attribute_name = "weight_dispensed" _unique_id_suffix = "weight_dispensed" _attr_translation_key: str = "weight_dispensed_today" _attr_native_unit_of_measurement = UnitOfMass.GRAMS @@ -1032,7 +1032,7 @@ class AqaraPetFeederWeightDispensed(Sensor): class AqaraSmokeDensityDbm(Sensor): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" - SENSOR_ATTR = "smoke_density_dbm" + _attribute_name = "smoke_density_dbm" _unique_id_suffix = "smoke_density_dbm" _attr_translation_key: str = "smoke_density" _attr_native_unit_of_measurement = "dB/m" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 495c9470e53..e49bc44b822 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -168,8 +168,8 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """Representation of a ZHA switch configuration entity.""" _attr_entity_category = EntityCategory.CONFIG - _zcl_attribute: str - _zcl_inverter_attribute: str | None = None + _attribute_name: str + _inverter_attribute_name: str | None = None _force_inverted: bool = False @classmethod @@ -186,13 +186,13 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._zcl_attribute in cluster_handler.cluster.unsupported_attributes - or cls._zcl_attribute not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._zcl_attribute) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._zcl_attribute, + cls._attribute_name, cls.__name__, ) return None @@ -225,20 +225,22 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): @property def inverted(self) -> bool: """Return True if the switch is inverted.""" - if self._zcl_inverter_attribute: - return bool(self._cluster_handler.cluster.get(self._zcl_inverter_attribute)) + if self._inverter_attribute_name: + return bool( + self._cluster_handler.cluster.get(self._inverter_attribute_name) + ) return self._force_inverted @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - val = bool(self._cluster_handler.cluster.get(self._zcl_attribute)) + val = bool(self._cluster_handler.cluster.get(self._attribute_name)) return (not val) if self.inverted else val async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" await self._cluster_handler.write_attributes_safe( - {self._zcl_attribute: not state if self.inverted else state} + {self._attribute_name: not state if self.inverted else state} ) self.async_write_ha_state() @@ -256,10 +258,10 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): self.error("Polling current state") if self._cluster_handler: value = await self._cluster_handler.get_attribute_value( - self._zcl_attribute, from_cache=False + self._attribute_name, from_cache=False ) await self._cluster_handler.get_attribute_value( - self._zcl_inverter_attribute, from_cache=False + self._inverter_attribute_name, from_cache=False ) self.debug("read value=%s, inverted=%s", value, self.inverted) @@ -274,8 +276,8 @@ class OnOffWindowDetectionFunctionConfigurationEntity(ZHASwitchConfigurationEnti """Representation of a ZHA window detection configuration entity.""" _unique_id_suffix = "on_off_window_opened_detection" - _zcl_attribute: str = "window_detection_function" - _zcl_inverter_attribute: str = "window_detection_function_inverter" + _attribute_name = "window_detection_function" + _inverter_attribute_name = "window_detection_function_inverter" _attr_translation_key = "window_detection_function" @@ -286,7 +288,7 @@ class P1MotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" _unique_id_suffix = "trigger_indicator" - _zcl_attribute: str = "trigger_indicator" + _attribute_name = "trigger_indicator" _attr_translation_key = "trigger_indicator" @@ -298,7 +300,7 @@ class XiaomiPlugPowerOutageMemorySwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA power outage memory configuration entity.""" _unique_id_suffix = "power_outage_memory" - _zcl_attribute: str = "power_outage_memory" + _attribute_name = "power_outage_memory" _attr_translation_key = "power_outage_memory" @@ -311,7 +313,7 @@ class HueMotionTriggerIndicatorSwitch(ZHASwitchConfigurationEntity): """Representation of a ZHA motion triggering configuration entity.""" _unique_id_suffix = "trigger_indicator" - _zcl_attribute: str = "trigger_indicator" + _attribute_name = "trigger_indicator" _attr_translation_key = "trigger_indicator" @@ -323,7 +325,7 @@ class ChildLock(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" _unique_id_suffix = "child_lock" - _zcl_attribute: str = "child_lock" + _attribute_name = "child_lock" _attr_translation_key = "child_lock" @@ -335,7 +337,7 @@ class DisableLed(ZHASwitchConfigurationEntity): """ZHA BinarySensor.""" _unique_id_suffix = "disable_led" - _zcl_attribute: str = "disable_led" + _attribute_name = "disable_led" _attr_translation_key = "disable_led" @@ -346,7 +348,7 @@ class InovelliInvertSwitch(ZHASwitchConfigurationEntity): """Inovelli invert switch control.""" _unique_id_suffix = "invert_switch" - _zcl_attribute: str = "invert_switch" + _attribute_name = "invert_switch" _attr_translation_key = "invert_switch" @@ -357,7 +359,7 @@ class InovelliSmartBulbMode(ZHASwitchConfigurationEntity): """Inovelli smart bulb mode control.""" _unique_id_suffix = "smart_bulb_mode" - _zcl_attribute: str = "smart_bulb_mode" + _attribute_name = "smart_bulb_mode" _attr_translation_key = "smart_bulb_mode" @@ -368,7 +370,7 @@ class InovelliDoubleTapUpEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap up enabled.""" _unique_id_suffix = "double_tap_up_enabled" - _zcl_attribute: str = "double_tap_up_enabled" + _attribute_name = "double_tap_up_enabled" _attr_translation_key = "double_tap_up_enabled" @@ -379,7 +381,7 @@ class InovelliDoubleTapDownEnabled(ZHASwitchConfigurationEntity): """Inovelli double tap down enabled.""" _unique_id_suffix = "double_tap_down_enabled" - _zcl_attribute: str = "double_tap_down_enabled" + _attribute_name = "double_tap_down_enabled" _attr_translation_key = "double_tap_down_enabled" @@ -390,7 +392,7 @@ class InovelliAuxSwitchScenes(ZHASwitchConfigurationEntity): """Inovelli unique aux switch scenes.""" _unique_id_suffix = "aux_switch_scenes" - _zcl_attribute: str = "aux_switch_scenes" + _attribute_name = "aux_switch_scenes" _attr_translation_key = "aux_switch_scenes" @@ -401,7 +403,7 @@ class InovelliBindingOffToOnSyncLevel(ZHASwitchConfigurationEntity): """Inovelli send move to level with on/off to bound devices.""" _unique_id_suffix = "binding_off_to_on_sync_level" - _zcl_attribute: str = "binding_off_to_on_sync_level" + _attribute_name = "binding_off_to_on_sync_level" _attr_translation_key = "binding_off_to_on_sync_level" @@ -412,7 +414,7 @@ class InovelliLocalProtection(ZHASwitchConfigurationEntity): """Inovelli local protection control.""" _unique_id_suffix = "local_protection" - _zcl_attribute: str = "local_protection" + _attribute_name = "local_protection" _attr_translation_key = "local_protection" @@ -423,7 +425,7 @@ class InovelliOnOffLEDMode(ZHASwitchConfigurationEntity): """Inovelli only 1 LED mode control.""" _unique_id_suffix = "on_off_led_mode" - _zcl_attribute: str = "on_off_led_mode" + _attribute_name = "on_off_led_mode" _attr_translation_key = "one_led_mode" @@ -434,7 +436,7 @@ class InovelliFirmwareProgressLED(ZHASwitchConfigurationEntity): """Inovelli firmware progress LED control.""" _unique_id_suffix = "firmware_progress_led" - _zcl_attribute: str = "firmware_progress_led" + _attribute_name = "firmware_progress_led" _attr_translation_key = "firmware_progress_led" @@ -445,7 +447,7 @@ class InovelliRelayClickInOnOffMode(ZHASwitchConfigurationEntity): """Inovelli relay click in on off mode control.""" _unique_id_suffix = "relay_click_in_on_off_mode" - _zcl_attribute: str = "relay_click_in_on_off_mode" + _attribute_name = "relay_click_in_on_off_mode" _attr_translation_key = "relay_click_in_on_off_mode" @@ -456,7 +458,7 @@ class InovelliDisableDoubleTapClearNotificationsMode(ZHASwitchConfigurationEntit """Inovelli disable clear notifications double tap control.""" _unique_id_suffix = "disable_clear_notifications_double_tap" - _zcl_attribute: str = "disable_clear_notifications_double_tap" + _attribute_name = "disable_clear_notifications_double_tap" _attr_translation_key = "disable_clear_notifications_double_tap" @@ -467,7 +469,7 @@ class AqaraPetFeederLEDIndicator(ZHASwitchConfigurationEntity): """Representation of a LED indicator configuration entity.""" _unique_id_suffix = "disable_led_indicator" - _zcl_attribute: str = "disable_led_indicator" + _attribute_name = "disable_led_indicator" _attr_translation_key = "led_indicator" _force_inverted = True _attr_icon: str = "mdi:led-on" @@ -480,7 +482,7 @@ class AqaraPetFeederChildLock(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" _unique_id_suffix = "child_lock" - _zcl_attribute: str = "child_lock" + _attribute_name = "child_lock" _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -493,7 +495,7 @@ class TuyaChildLockSwitch(ZHASwitchConfigurationEntity): """Representation of a child lock configuration entity.""" _unique_id_suffix = "child_lock" - _zcl_attribute: str = "child_lock" + _attribute_name = "child_lock" _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -505,7 +507,7 @@ class AqaraThermostatWindowDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat window detection configuration entity.""" _unique_id_suffix = "window_detection" - _zcl_attribute: str = "window_detection" + _attribute_name = "window_detection" _attr_translation_key = "window_detection" @@ -516,7 +518,7 @@ class AqaraThermostatValveDetection(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat valve detection configuration entity.""" _unique_id_suffix = "valve_detection" - _zcl_attribute: str = "valve_detection" + _attribute_name = "valve_detection" _attr_translation_key = "valve_detection" @@ -527,7 +529,7 @@ class AqaraThermostatChildLock(ZHASwitchConfigurationEntity): """Representation of an Aqara thermostat child lock configuration entity.""" _unique_id_suffix = "child_lock" - _zcl_attribute: str = "child_lock" + _attribute_name = "child_lock" _attr_translation_key = "child_lock" _attr_icon: str = "mdi:account-lock" @@ -539,7 +541,7 @@ class AqaraHeartbeatIndicator(ZHASwitchConfigurationEntity): """Representation of a heartbeat indicator configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "heartbeat_indicator" - _zcl_attribute: str = "heartbeat_indicator" + _attribute_name = "heartbeat_indicator" _attr_translation_key = "heartbeat_indicator" _attr_icon: str = "mdi:heart-flash" @@ -551,7 +553,7 @@ class AqaraLinkageAlarm(ZHASwitchConfigurationEntity): """Representation of a linkage alarm configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "linkage_alarm" - _zcl_attribute: str = "linkage_alarm" + _attribute_name = "linkage_alarm" _attr_translation_key = "linkage_alarm" _attr_icon: str = "mdi:shield-link-variant" @@ -563,7 +565,7 @@ class AqaraBuzzerManualMute(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "buzzer_manual_mute" - _zcl_attribute: str = "buzzer_manual_mute" + _attribute_name = "buzzer_manual_mute" _attr_translation_key = "buzzer_manual_mute" _attr_icon: str = "mdi:volume-off" @@ -575,6 +577,6 @@ class AqaraBuzzerManualAlarm(ZHASwitchConfigurationEntity): """Representation of a buzzer manual mute configuration entity for Aqara smoke sensors.""" _unique_id_suffix = "buzzer_manual_alarm" - _zcl_attribute: str = "buzzer_manual_alarm" + _attribute_name = "buzzer_manual_alarm" _attr_translation_key = "buzzer_manual_alarm" _attr_icon: str = "mdi:bullhorn" From d149bffb070934fc2d5a6430004f22085e4f6881 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 17:34:43 +0200 Subject: [PATCH 558/968] Do not fail MQTT setup if lights configured via yaml can't be validated (#101649) * Add light * Deduplicate code * Follow up comment --- .../components/mqtt/config_integration.py | 6 +- .../components/mqtt/light/__init__.py | 41 ++---- .../components/mqtt/light/schema_basic.py | 17 +-- .../components/mqtt/light/schema_json.py | 17 +-- .../components/mqtt/light/schema_template.py | 17 +-- homeassistant/components/mqtt/mixins.py | 135 ++++++++++-------- tests/components/mqtt/test_init.py | 17 +-- tests/components/mqtt/test_light.py | 5 +- tests/components/mqtt/test_light_json.py | 18 +-- 9 files changed, 107 insertions(+), 166 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 975ddfe6386..da42e6f42d5 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -26,7 +26,6 @@ from . import ( humidifier as humidifier_platform, image as image_platform, lawn_mower as lawn_mower_platform, - light as light_platform, lock as lock_platform, number as number_platform, scene as scene_platform, @@ -100,14 +99,11 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), + Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All( cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.LIGHT.value: vol.All( - cv.ensure_list, - [light_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), Platform.NUMBER.value: vol.All( cv.ensure_list, [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 2c70490ac5e..15431616658 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,7 +1,6 @@ """Support for MQTT lights.""" from __future__ import annotations -import functools from typing import Any import voluptuous as vol @@ -10,24 +9,24 @@ from homeassistant.components import light from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType -from ..mixins import async_setup_entry_helper +from ..mixins import async_mqtt_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, PLATFORM_SCHEMA_MODERN_BASIC, - async_setup_entity_basic, + MqttLight, ) from .schema_json import ( DISCOVERY_SCHEMA_JSON, PLATFORM_SCHEMA_MODERN_JSON, - async_setup_entity_json, + MqttLightJson, ) from .schema_template import ( DISCOVERY_SCHEMA_TEMPLATE, PLATFORM_SCHEMA_MODERN_TEMPLATE, - async_setup_entity_template, + MqttLightTemplate, ) @@ -70,25 +69,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry - ) - await async_setup_entry_helper(hass, light.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up a MQTT Light.""" - setup_entity = { - "basic": async_setup_entity_basic, - "json": async_setup_entity_json, - "template": async_setup_entity_template, - } - await setup_entity[config[CONF_SCHEMA]]( - hass, config, async_add_entities, config_entry, discovery_data + await async_mqtt_entry_helper( + hass, + config_entry, + None, + light.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + {"basic": MqttLight, "json": MqttLightJson, "template": MqttLightTemplate}, ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 65c05501658..2ca0a7e7e47 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -28,7 +28,6 @@ from homeassistant.components.light import ( LightEntityFeature, valid_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -36,11 +35,10 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from .. import subscription @@ -228,17 +226,6 @@ DISCOVERY_SCHEMA_BASIC = vol.All( ) -async def async_setup_entity_basic( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Light.""" - async_add_entities([MqttLight(hass, config, config_entry, discovery_data)]) - - class MqttLight(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT light.""" diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 462280b1516..6f70ff34051 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -32,7 +32,6 @@ from homeassistant.components.light import ( filter_supported_color_modes, valid_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, @@ -44,12 +43,11 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from homeassistant.util.json import json_loads_object @@ -166,17 +164,6 @@ PLATFORM_SCHEMA_MODERN_JSON = vol.All( ) -async def async_setup_entity_json( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson(hass, config, config_entry, discovery_data)]) - - class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT JSON light.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index a225ce43efa..e4900053fb3 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -20,7 +20,6 @@ from homeassistant.components.light import ( LightEntityFeature, filter_supported_color_modes, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, @@ -28,11 +27,10 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType import homeassistant.util.color as color_util from .. import subscription @@ -113,17 +111,6 @@ DISCOVERY_SCHEMA_TEMPLATE = vol.All( ) -async def async_setup_entity_template( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Template light.""" - async_add_entities([MqttLightTemplate(hass, config, config_entry, discovery_data)]) - - class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): """Representation of a MQTT Template light.""" diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 1138663c851..ddc2703d820 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Coroutine +import functools from functools import partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final @@ -81,6 +82,7 @@ from .const import ( CONF_OBJECT_ID, CONF_ORIGIN, CONF_QOS, + CONF_SCHEMA, CONF_SUGGESTED_AREA, CONF_SW_VERSION, CONF_TOPIC, @@ -272,6 +274,38 @@ def async_handle_schema_error( ) +async def _async_discover( + hass: HomeAssistant, + domain: str, + async_setup: partial[Coroutine[Any, Any, None]], + discovery_payload: MQTTDiscoveryPayload, +) -> None: + """Discover and add an MQTT entity, automation or tag.""" + if not mqtt_config_entry_enabled(hass): + _LOGGER.warning( + ( + "MQTT integration is disabled, skipping setup of discovered item " + "MQTT %s, payload %s" + ), + domain, + discovery_payload, + ) + return + discovery_data = discovery_payload.discovery_data + try: + await async_setup(discovery_payload) + except vol.Invalid as err: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + async_handle_schema_error(discovery_payload, err) + except Exception: + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] + clear_discovery_hash(hass, discovery_hash) + async_dispatcher_send(hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None) + raise + + async def async_setup_entry_helper( hass: HomeAssistant, domain: str, @@ -281,43 +315,25 @@ async def async_setup_entry_helper( """Set up entity, automation or tag creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) - async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: - """Discover and add an MQTT entity, automation or tag.""" - if not mqtt_config_entry_enabled(hass): - _LOGGER.warning( - ( - "MQTT integration is disabled, skipping setup of discovered item " - "MQTT %s, payload %s" - ), - domain, - discovery_payload, - ) - return - discovery_data = discovery_payload.discovery_data - try: - config: DiscoveryInfoType = discovery_schema(discovery_payload) - await async_setup(config, discovery_data=discovery_data) - except vol.Invalid as err: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - async_handle_schema_error(discovery_payload, err) - except Exception: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - raise + async def async_setup_from_discovery( + discovery_payload: MQTTDiscoveryPayload, + ) -> None: + """Set up an MQTT entity, automation or tag from discovery.""" + config: DiscoveryInfoType = discovery_schema(discovery_payload) + await async_setup(config, discovery_data=discovery_payload.discovery_data) mqtt_data.reload_dispatchers.append( async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover + hass, + MQTT_DISCOVERY_NEW.format(domain, "mqtt"), + functools.partial( + _async_discover, hass, domain, async_setup_from_discovery + ), ) ) + # The setup of manual configured MQTT entities will be migrated to async_mqtt_entry_helper. + # The following setup code will be cleaned up after the last entity platform has been migrated. async def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" mqtt_data = get_mqtt_data(hass) @@ -342,54 +358,43 @@ async def async_setup_entry_helper( async def async_mqtt_entry_helper( hass: HomeAssistant, entry: ConfigEntry, - entity_class: type[MqttEntity], + entity_class: type[MqttEntity] | None, domain: str, async_add_entities: AddEntitiesCallback, discovery_schema: vol.Schema, platform_schema_modern: vol.Schema, + schema_class_mapping: dict[str, type[MqttEntity]] | None = None, ) -> None: """Set up entity, automation or tag creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) - async def async_discover(discovery_payload: MQTTDiscoveryPayload) -> None: - """Discover and add an MQTT entity, automation or tag.""" - if not mqtt_config_entry_enabled(hass): - _LOGGER.warning( - ( - "MQTT integration is disabled, skipping setup of discovered item " - "MQTT %s, payload %s" - ), - domain, - discovery_payload, - ) - return - discovery_data = discovery_payload.discovery_data - try: - config: DiscoveryInfoType = discovery_schema(discovery_payload) - async_add_entities([entity_class(hass, config, entry, discovery_data)]) - except vol.Invalid as err: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - async_handle_schema_error(discovery_payload, err) - except Exception: - discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] - clear_discovery_hash(hass, discovery_hash) - async_dispatcher_send( - hass, MQTT_DISCOVERY_DONE.format(discovery_hash), None - ) - raise + async def async_setup_from_discovery( + discovery_payload: MQTTDiscoveryPayload, + ) -> None: + """Set up an MQTT entity from discovery.""" + nonlocal entity_class + config: DiscoveryInfoType = discovery_schema(discovery_payload) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None + async_add_entities( + [entity_class(hass, config, entry, discovery_payload.discovery_data)] + ) mqtt_data.reload_dispatchers.append( async_dispatcher_connect( - hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), async_discover + hass, + MQTT_DISCOVERY_NEW.format(domain, "mqtt"), + functools.partial( + _async_discover, hass, domain, async_setup_from_discovery + ), ) ) async def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" + nonlocal entity_class mqtt_data = get_mqtt_data(hass) if not (config_yaml := mqtt_data.config): return @@ -404,6 +409,10 @@ async def async_mqtt_entry_helper( for yaml_config in yaml_configs: try: config = platform_schema_modern(yaml_config) + if schema_class_mapping is not None: + entity_class = schema_class_mapping[config[CONF_SCHEMA]] + if TYPE_CHECKING: + assert entity_class is not None entities.append(entity_class(hass, config, entry, None)) except vol.Invalid as ex: error = str(ex) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 2c6ee5d1b20..dc81e3d82b9 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2114,35 +2114,30 @@ async def test_handle_message_callback( } ], ) -@patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_platform_key( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test set up a manual MQTT item with a platform key.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( - "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" + "extra keys not allowed @ data['platform'] for manual configured MQTT light item" in caplog.text ) @pytest.mark.parametrize("hass_config", [{mqtt.DOMAIN: {"light": {"name": "test"}}}]) -@patch("homeassistant.components.mqtt.PLATFORMS", []) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_invalid_config( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: """Test set up a manual MQTT item with an invalid config.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: required key not provided @ data['mqtt'][0]['light'][0]['command_topic']. " - "Got None. (See ?, line ?)" in caplog.text - ) + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @patch("homeassistant.components.mqtt.PLATFORMS", []) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 58d37943403..7de6c08f269 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -253,9 +253,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 7df4dbc6e82..e7471829856 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -197,9 +197,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails with no command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -217,12 +216,8 @@ async def test_fail_setup_if_color_mode_deprecated( caplog: pytest.LogCaptureFixture, ) -> None: """Test if setup fails if color mode is combined with deprecated config keys.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: color_mode must not be combined with any of" - in caplog.text - ) + assert await mqtt_mock_entry() + assert "color_mode must not be combined with any of" in caplog.text @pytest.mark.parametrize( @@ -250,7 +245,7 @@ async def test_fail_setup_if_color_mode_deprecated( COLOR_MODES_CONFIG, ({"supported_color_modes": ["unknown"]},), ), - "Invalid config for [mqtt]: value must be one of [ None: """Test if setup fails if supported color modes is invalid.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert error in caplog.text From 651b725cc0623efe632b65d4d8035c16108b6057 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:15:21 +0200 Subject: [PATCH 559/968] Do not fail MQTT setup if binary sensors configured via yaml can't be validated (#102300) Add binary_sensor --- .../components/mqtt/binary_sensor.py | 27 +++++++------------ .../components/mqtt/config_integration.py | 6 +---- tests/components/mqtt/test_binary_sensor.py | 5 ++-- 3 files changed, 13 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 7eb444b046a..b8f7c73eede 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from datetime import datetime, timedelta -import functools import logging from typing import Any @@ -31,7 +30,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import subscription @@ -42,7 +41,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -77,21 +76,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttBinarySensor, + binary_sensor.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, binary_sensor.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT binary sensor.""" - async_add_entities([MqttBinarySensor(hass, config, config_entry, discovery_data)]) class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index da42e6f42d5..6c1692fdb1b 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -15,7 +15,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from . import ( - binary_sensor as binary_sensor_platform, button as button_platform, camera as camera_platform, climate as climate_platform, @@ -55,10 +54,7 @@ DEFAULT_TLS_PROTOCOL = "auto" CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]), - Platform.BINARY_SENSOR.value: vol.All( - cv.ensure_list, - [binary_sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.BINARY_SENSOR.value: vol.All(cv.ensure_list, [dict]), Platform.BUTTON.value: vol.All( cv.ensure_list, [button_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index e7a4c9ab1aa..3cc04b79e3a 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -580,9 +580,8 @@ async def test_invalid_device_class( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid sensor class.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: expected BinarySensorDeviceClass" in caplog.text + assert await mqtt_mock_entry() + assert "expected BinarySensorDeviceClass" in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From fb984b5218cfb98a82457f54fd576cd3f938d376 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:17:06 +0200 Subject: [PATCH 560/968] Do not fail MQTT setup if camera's configured via yaml can't be validated (#102302) Add camera --- homeassistant/components/mqtt/camera.py | 25 +++++++------------ .../components/mqtt/config_integration.py | 6 +---- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index c8402e501b0..1a2d4744948 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -2,7 +2,6 @@ from __future__ import annotations from base64 import b64decode -import functools import logging from typing import TYPE_CHECKING @@ -21,7 +20,7 @@ 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 MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -61,21 +60,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttCamera, + camera.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, camera.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Camera.""" - async_add_entities([MqttCamera(hass, config, config_entry, discovery_data)]) class MqttCamera(MqttEntity, Camera): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 6c1692fdb1b..a57b9499d9e 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -16,7 +16,6 @@ from homeassistant.helpers import config_validation as cv from . import ( button as button_platform, - camera as camera_platform, climate as climate_platform, cover as cover_platform, device_tracker as device_tracker_platform, @@ -59,10 +58,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [button_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.CAMERA.value: vol.All( - cv.ensure_list, - [camera_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), Platform.CLIMATE.value: vol.All( cv.ensure_list, [climate_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] From 3a4341dbeb3d37ee1c181ff4bc9d8bb38d2f510f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:18:04 +0200 Subject: [PATCH 561/968] Do not fail MQTT setup if device trackers configured via yaml can't be validated (#102308) Add device_tracker --- .../components/mqtt/config_integration.py | 6 +--- .../components/mqtt/device_tracker.py | 29 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index a57b9499d9e..713152616ef 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -18,7 +18,6 @@ from . import ( button as button_platform, climate as climate_platform, cover as cover_platform, - device_tracker as device_tracker_platform, event as event_platform, fan as fan_platform, humidifier as humidifier_platform, @@ -67,10 +66,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [cover_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.DEVICE_TRACKER.value: vol.All( - cv.ensure_list, - [device_tracker_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All( cv.ensure_list, [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 1293121e0a8..1216a68fe7b 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools from typing import TYPE_CHECKING import voluptuous as vol @@ -26,7 +25,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA @@ -36,7 +35,7 @@ from .mixins import ( CONF_JSON_ATTRS_TOPIC, MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType @@ -85,22 +84,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT device_tracker through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT event through YAML and through MQTT discovery.""" + await async_mqtt_entry_helper( + hass, + config_entry, + MqttDeviceTracker, + device_tracker.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, device_tracker.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Device Tracker entity.""" - async_add_entities([MqttDeviceTracker(hass, config, config_entry, discovery_data)]) class MqttDeviceTracker(MqttEntity, TrackerEntity): From eab4c24f7ffec7bdd4c96c1545576b36b29e91be Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 19 Oct 2023 18:22:22 +0200 Subject: [PATCH 562/968] Fix KeyError in derivative and integration (#102294) --- homeassistant/components/derivative/sensor.py | 6 +++++- homeassistant/components/integration/sensor.py | 6 +++++- homeassistant/components/template/config_flow.py | 3 +-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index a9c15dfe25e..73d297d7541 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -116,6 +116,10 @@ async def async_setup_entry( else: device_info = None + if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing + unit_prefix = None + derivative_sensor = DerivativeSensor( name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), @@ -123,7 +127,7 @@ async def async_setup_entry( time_window=cv.time_period_dict(config_entry.options[CONF_TIME_WINDOW]), unique_id=config_entry.entry_id, unit_of_measurement=None, - unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), + unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, ) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 909266c51d4..d7d5c84f17a 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -169,13 +169,17 @@ async def async_setup_entry( else: device_info = None + if (unit_prefix := config_entry.options.get(CONF_UNIT_PREFIX)) == "none": + # Before we had support for optional selectors, "none" was used for selecting nothing + unit_prefix = None + integral = IntegrationSensor( integration_method=config_entry.options[CONF_METHOD], name=config_entry.title, round_digits=int(config_entry.options[CONF_ROUND_DIGITS]), source_entity=source_entity_id, unique_id=config_entry.entry_id, - unit_prefix=config_entry.options.get(CONF_UNIT_PREFIX), + unit_prefix=unit_prefix, unit_time=config_entry.options[CONF_UNIT_TIME], device_info=device_info, ) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index cd6b7c8937f..686c12fa4ba 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -176,8 +176,7 @@ def validate_user_input( ]: """Do post validation of user input. - For binary sensors: Strip none-sentinels. - For sensors: Strip none-sentinels and validate unit of measurement. + For sensors: Validate unit of measurement. For all domaines: Set template type. """ From c266583beac236bc1534c9052d7f9987714f6fc2 Mon Sep 17 00:00:00 2001 From: GeoffAtHome Date: Thu, 19 Oct 2023 17:26:34 +0100 Subject: [PATCH 563/968] Smart plugs appear as Switches and Binary Sensors (#102112) --- homeassistant/components/geniushub/binary_sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py index f00019361e5..a2f71bfd9fe 100644 --- a/homeassistant/components/geniushub/binary_sensor.py +++ b/homeassistant/components/geniushub/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN, GeniusDevice GH_STATE_ATTR = "outputOnOff" +GH_TYPE = "Receiver" async def async_setup_platform( @@ -26,7 +27,7 @@ async def async_setup_platform( switches = [ GeniusBinarySensor(broker, d, GH_STATE_ATTR) for d in broker.client.device_objs - if GH_STATE_ATTR in d.data["state"] + if GH_TYPE in d.data["type"] ] async_add_entities(switches, update_before_add=True) From 615b02be596eb2285e4b49d8ab75d415e481ef56 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:27:04 +0200 Subject: [PATCH 564/968] Do not fail MQTT setup if scenes configured via yaml can't be validated (#102317) Add scene --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/scene.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 713152616ef..cb66f0d3596 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -25,7 +25,6 @@ from . import ( lawn_mower as lawn_mower_platform, lock as lock_platform, number as number_platform, - scene as scene_platform, select as select_platform, sensor as sensor_platform, siren as siren_platform, @@ -96,10 +95,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.SCENE.value: vol.All( - cv.ensure_list, - [scene_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All( cv.ensure_list, [select_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 9e7c280cbc0..7e41e5e8592 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -1,7 +1,6 @@ """Support for MQTT scenes.""" from __future__ import annotations -import functools from typing import Any import voluptuous as vol @@ -13,11 +12,11 @@ from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" @@ -43,21 +42,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttScene, + scene.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, scene.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT scene.""" - async_add_entities([MqttScene(hass, config, config_entry, discovery_data)]) class MqttScene( From c408b60e4e6626bd4f61359df34d5a27129c6646 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 06:30:20 -1000 Subject: [PATCH 565/968] Reduce internal property lookups needed to write number entity state (#102281) --- homeassistant/components/number/__init__.py | 46 ++++++++++++--------- 1 file changed, 26 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index ad2b7d55ff8..97221aaca90 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -218,10 +218,12 @@ class NumberEntity(Entity): @property def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" + min_value = self.min_value + max_value = self.max_value return { - ATTR_MIN: self.min_value, - ATTR_MAX: self.max_value, - ATTR_STEP: self.step, + ATTR_MIN: min_value, + ATTR_MAX: max_value, + ATTR_STEP: self._calculate_step(min_value, max_value), ATTR_MODE: self.mode, } @@ -290,13 +292,17 @@ class NumberEntity(Entity): @property @final def step(self) -> float: + """Return the increment/decrement step.""" + return self._calculate_step(self.min_value, self.max_value) + + def _calculate_step(self, min_value: float, max_value: float) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_native_step"): return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP - value_range = abs(self.max_value - self.min_value) + value_range = abs(max_value - min_value) if value_range != 0: while value_range <= step: step /= 10.0 @@ -337,11 +343,12 @@ class NumberEntity(Entity): return self._number_option_unit_of_measurement native_unit_of_measurement = self.native_unit_of_measurement - + # device_class is checked after native_unit_of_measurement since most + # of the time we can avoid the device_class check if ( - self.device_class == NumberDeviceClass.TEMPERATURE - and native_unit_of_measurement + native_unit_of_measurement in (UnitOfTemperature.CELSIUS, UnitOfTemperature.FAHRENHEIT) + and self.device_class == NumberDeviceClass.TEMPERATURE ): return self.hass.config.units.temperature_unit @@ -382,14 +389,14 @@ class NumberEntity(Entity): self, value: float, method: Callable[[float, int], float] ) -> float: """Convert a value in the number's native unit to the configured unit.""" + # device_class is checked first since most of the time we can avoid + # the unit conversion + if (device_class := self.device_class) not in UNIT_CONVERTERS: + return value + native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement - device_class = self.device_class - - if ( - native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS - ): + if native_unit_of_measurement != unit_of_measurement: assert native_unit_of_measurement assert unit_of_measurement @@ -411,15 +418,14 @@ class NumberEntity(Entity): def convert_to_native_value(self, value: float) -> float: """Convert a value to the number's native unit.""" + # device_class is checked first since most of the time we can avoid + # the unit conversion + if value is None or (device_class := self.device_class) not in UNIT_CONVERTERS: + return value + native_unit_of_measurement = self.native_unit_of_measurement unit_of_measurement = self.unit_of_measurement - device_class = self.device_class - - if ( - value is not None - and native_unit_of_measurement != unit_of_measurement - and device_class in UNIT_CONVERTERS - ): + if native_unit_of_measurement != unit_of_measurement: assert native_unit_of_measurement assert unit_of_measurement From 3853214496e1eff3844705d68f3c45b87d96a3d4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:48:35 +0200 Subject: [PATCH 566/968] Do not fail MQTT setup if fans configured via yaml can't be validated (#102310) Add fan --- .../components/mqtt/config_integration.py | 6 +-- homeassistant/components/mqtt/fan.py | 27 +++++-------- tests/components/mqtt/test_fan.py | 39 +++++++++++++------ 3 files changed, 39 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index cb66f0d3596..d0a5f10fc4b 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -19,7 +19,6 @@ from . import ( climate as climate_platform, cover as cover_platform, event as event_platform, - fan as fan_platform, humidifier as humidifier_platform, image as image_platform, lawn_mower as lawn_mower_platform, @@ -70,10 +69,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.FAN.value: vol.All( - cv.ensure_list, - [fan_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.FAN.value: vol.All(cv.ensure_list, [dict]), Platform.HUMIDIFIER.value: vol.All( cv.ensure_list, [humidifier_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 0aad3a6afc0..783573c8e00 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import math from typing import Any @@ -30,7 +29,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.percentage import ( int_states_in_range, percentage_to_ranged_value, @@ -53,7 +52,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -200,21 +199,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttFan, + fan.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, fan.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT fan.""" - async_add_entities([MqttFan(hass, config, config_entry, discovery_data)]) class MqttFan(MqttEntity, FanEntity): diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index fe354817aef..6642d778f53 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -96,9 +96,8 @@ async def test_fail_setup_if_no_command_topic( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -1584,7 +1583,7 @@ async def test_attributes( @pytest.mark.parametrize( - ("name", "hass_config", "success", "features"), + ("name", "hass_config", "success", "features", "error_message"), [ ( "test1", @@ -1598,6 +1597,7 @@ async def test_attributes( }, True, fan.FanEntityFeature(0), + None, ), ( "test2", @@ -1612,6 +1612,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.OSCILLATE, + None, ), ( "test3", @@ -1626,6 +1627,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test4", @@ -1640,6 +1642,7 @@ async def test_attributes( }, False, None, + "some but not all values in the same group of inclusion 'preset_modes'", ), ( "test5", @@ -1655,6 +1658,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test6", @@ -1670,6 +1674,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test7", @@ -1684,6 +1689,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test8", @@ -1699,6 +1705,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.OSCILLATE | fan.FanEntityFeature.SET_SPEED, + None, ), ( "test9", @@ -1714,6 +1721,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test10", @@ -1729,6 +1737,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + None, ), ( "test11", @@ -1745,6 +1754,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE | fan.FanEntityFeature.OSCILLATE, + None, ), ( "test12", @@ -1761,6 +1771,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.SET_SPEED, + None, ), ( "test13", @@ -1777,6 +1788,7 @@ async def test_attributes( }, False, None, + "not a valid value", ), ( "test14", @@ -1793,13 +1805,14 @@ async def test_attributes( }, False, None, + "not a valid value", ), ( "test15", { mqtt.DOMAIN: { fan.DOMAIN: { - "name": "test7reset_payload_in_preset_modes_a", + "name": "test15", "command_topic": "command-topic", "preset_mode_command_topic": "preset-mode-command-topic", "preset_modes": ["auto", "smart", "normal", "None"], @@ -1808,6 +1821,7 @@ async def test_attributes( }, False, None, + "preset_modes must not contain payload_reset_preset_mode", ), ( "test16", @@ -1824,6 +1838,7 @@ async def test_attributes( }, True, fan.FanEntityFeature.PRESET_MODE, + "some error", ), ( "test17", @@ -1838,25 +1853,27 @@ async def test_attributes( }, True, fan.FanEntityFeature.DIRECTION, + "some error", ), ], ) async def test_supported_features( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, name: str, success: bool, - features, + features: fan.FanEntityFeature | None, + error_message: str | None, ) -> None: """Test optimistic mode without state topic.""" + await mqtt_mock_entry() + state = hass.states.get(f"fan.{name}") + assert (state is not None) == success if success: - await mqtt_mock_entry() - - state = hass.states.get(f"fan.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert error_message in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From d0341c9754ffd1beab981a3a76e2fdc8524dcca9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:50:02 +0200 Subject: [PATCH 567/968] Do not fail MQTT setup if images configured via yaml can't be validated (#102313) Add image --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/image.py | 25 +++++++------------ tests/components/mqtt/test_image.py | 6 ++--- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index d0a5f10fc4b..291c1af3276 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -20,7 +20,6 @@ from . import ( cover as cover_platform, event as event_platform, humidifier as humidifier_platform, - image as image_platform, lawn_mower as lawn_mower_platform, lock as lock_platform, number as number_platform, @@ -74,10 +73,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [humidifier_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.IMAGE.value: vol.All( - cv.ensure_list, - [image_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.IMAGE.value: vol.All(cv.ensure_list, [dict]), Platform.LAWN_MOWER.value: vol.All( cv.ensure_list, [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index da526575a77..bf4ca584c47 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -4,7 +4,6 @@ from __future__ import annotations from base64 import b64decode import binascii from collections.abc import Callable -import functools import logging from typing import TYPE_CHECKING, Any @@ -27,7 +26,7 @@ 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 MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_subscribe_topic @@ -79,21 +78,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttImage, + image.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, image.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Image.""" - async_add_entities([MqttImage(hass, config, config_entry, discovery_data)]) class MqttImage(MqttEntity, ImageEntity): diff --git a/tests/components/mqtt/test_image.py b/tests/components/mqtt/test_image.py index 621be984b7b..5ca9bbbc297 100644 --- a/tests/components/mqtt/test_image.py +++ b/tests/components/mqtt/test_image.py @@ -1,6 +1,5 @@ """The tests for mqtt image component.""" from base64 import b64encode -from contextlib import suppress from http import HTTPStatus import json import ssl @@ -504,7 +503,7 @@ async def test_image_from_url_fails( } } }, - "Invalid config for [mqtt]: Expected one of [`image_topic`, `url_topic`], got none", + "Expected one of [`image_topic`, `url_topic`], got none", ), ], ) @@ -516,8 +515,7 @@ async def test_image_config_fails( error_msg: str, ) -> None: """Test setup with minimum configuration.""" - with suppress(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert error_msg in caplog.text From 1456809f6aaa473801f5e852e28cb6022444ec02 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:50:50 +0200 Subject: [PATCH 568/968] Do not fail MQTT setup if sirens configured via yaml can't be validated (#102319) Add siren --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/siren.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 291c1af3276..82f83fd50a0 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -25,7 +25,6 @@ from . import ( number as number_platform, select as select_platform, sensor as sensor_platform, - siren as siren_platform, switch as switch_platform, text as text_platform, update as update_platform, @@ -96,10 +95,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.SIREN.value: vol.All( - cv.ensure_list, - [siren_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), Platform.SWITCH.value: vol.All( cv.ensure_list, [switch_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 7978776a089..3ba7df84cc9 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any, cast @@ -32,7 +31,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription @@ -52,7 +51,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -122,21 +121,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttSiren, + siren.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, siren.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT siren.""" - async_add_entities([MqttSiren(hass, config, config_entry, discovery_data)]) class MqttSiren(MqttEntity, SirenEntity): From 5eb0a337956b3d53aa3bee115e0d03e771caa143 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 18:51:47 +0200 Subject: [PATCH 569/968] Do not fail MQTT setup if text's configured via yaml can't be validated (#102322) Add text --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/text.py | 27 +++++++------------ tests/components/mqtt/test_text.py | 16 ++++++----- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 82f83fd50a0..f5005390dc3 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -26,7 +26,6 @@ from . import ( select as select_platform, sensor as sensor_platform, switch as switch_platform, - text as text_platform, update as update_platform, vacuum as vacuum_platform, water_heater as water_heater_platform, @@ -100,10 +99,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [switch_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.TEXT.value: vol.All( - cv.ensure_list, - [text_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All( cv.ensure_list, [update_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 630951f171e..3fd0f9a4198 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import re from typing import Any @@ -22,7 +21,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -38,7 +37,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -108,21 +107,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttTextEntity, + text.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, text.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT text.""" - async_add_entities([MqttTextEntity(hass, config, config_entry, discovery_data)]) class MqttTextEntity(MqttEntity, TextEntity): diff --git a/tests/components/mqtt/test_text.py b/tests/components/mqtt/test_text.py index bf6fe1b0130..80f38dffcf9 100644 --- a/tests/components/mqtt/test_text.py +++ b/tests/components/mqtt/test_text.py @@ -205,11 +205,13 @@ async def test_controlling_validation_state_via_topic( ], ) async def test_attribute_validation_max_greater_then_min( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the validation of min and max configuration attributes.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( @@ -228,11 +230,13 @@ async def test_attribute_validation_max_greater_then_min( ], ) async def test_attribute_validation_max_not_greater_then_max_state_length( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test the max value of of max configuration attribute.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( From c574cefc30a4bc6024e5f3b751c57696367c4116 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 19 Oct 2023 19:15:06 +0200 Subject: [PATCH 570/968] Bump aiocomelit to 0.3.0 (#102340) * Bump aiocomelit to 0.3.0 * missing string --- homeassistant/components/comelit/config_flow.py | 4 ++-- homeassistant/components/comelit/const.py | 4 ---- homeassistant/components/comelit/coordinator.py | 2 +- homeassistant/components/comelit/cover.py | 16 ++++++++-------- homeassistant/components/comelit/light.py | 4 ++-- homeassistant/components/comelit/manifest.json | 2 +- homeassistant/components/comelit/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/comelit/const.py | 2 ++ tests/components/comelit/test_config_flow.py | 6 +++--- 11 files changed, 22 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/comelit/config_flow.py b/homeassistant/components/comelit/config_flow.py index 66ab9ae88b3..b95853edf9d 100644 --- a/homeassistant/components/comelit/config_flow.py +++ b/homeassistant/components/comelit/config_flow.py @@ -16,7 +16,7 @@ import homeassistant.helpers.config_validation as cv from .const import _LOGGER, DEFAULT_PORT, DOMAIN DEFAULT_HOST = "192.168.1.252" -DEFAULT_PIN = "111111" +DEFAULT_PIN = 111111 def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: @@ -31,7 +31,7 @@ def user_form_schema(user_input: dict[str, Any] | None) -> vol.Schema: ) -STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): str}) +STEP_REAUTH_DATA_SCHEMA = vol.Schema({vol.Required(CONF_PIN): cv.positive_int}) async def validate_input( diff --git a/homeassistant/components/comelit/const.py b/homeassistant/components/comelit/const.py index 7bd49440eb3..57b7f35bc17 100644 --- a/homeassistant/components/comelit/const.py +++ b/homeassistant/components/comelit/const.py @@ -5,7 +5,3 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "comelit" DEFAULT_PORT = 80 - -# Entity states -STATE_OFF = 0 -STATE_ON = 1 diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 02a8d805d19..1fc4b0e6668 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,7 +68,7 @@ class ComelitSerialBridge(DataUpdateCoordinator): ) async def _async_update_data(self) -> dict[str, Any]: - """Update router data.""" + """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) logged = False try: diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 08e1136ca9e..8bccd12e9a5 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import COVER, COVER_CLOSE, COVER_OPEN, COVER_STATUS +from aiocomelit.const import COVER, STATE_COVER, STATE_OFF, STATE_ON from homeassistant.components.cover import STATE_CLOSED, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -60,9 +60,9 @@ class ComelitCoverEntity( def _current_action(self, action: str) -> bool: """Return the current cover action.""" - is_moving = self.device_status == COVER_STATUS.index(action) + is_moving = self.device_status == STATE_COVER.index(action) if is_moving: - self._last_action = COVER_STATUS.index(action) + self._last_action = STATE_COVER.index(action) return is_moving @property @@ -77,11 +77,11 @@ class ComelitCoverEntity( if self._last_state in [None, "unknown"]: return None - if self.device_status != COVER_STATUS.index("stopped"): + if self.device_status != STATE_COVER.index("stopped"): return False if self._last_action: - return self._last_action == COVER_STATUS.index("closing") + return self._last_action == STATE_COVER.index("closing") return self._last_state == STATE_CLOSED @@ -97,18 +97,18 @@ class ComelitCoverEntity( async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" - await self._api.set_device_status(COVER, self._device.index, COVER_CLOSE) + await self._api.set_device_status(COVER, self._device.index, STATE_OFF) async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" - await self._api.set_device_status(COVER, self._device.index, COVER_OPEN) + await self._api.set_device_status(COVER, self._device.index, STATE_ON) async def async_stop_cover(self, **_kwargs: Any) -> None: """Stop the cover.""" if not self.is_closing and not self.is_opening: return - action = COVER_OPEN if self.is_closing else COVER_CLOSE + action = STATE_OFF if self.is_closing else STATE_ON await self._api.set_device_status(COVER, self._device.index, action) @callback diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 1bdf3e6a87b..15c4ec8cc7e 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import LIGHT +from aiocomelit.const import LIGHT, STATE_OFF, STATE_ON from homeassistant.components.light import LightEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, STATE_OFF, STATE_ON +from .const import DOMAIN from .coordinator import ComelitSerialBridge diff --git a/homeassistant/components/comelit/manifest.json b/homeassistant/components/comelit/manifest.json index 7da4f70ce99..5978f17cfc4 100644 --- a/homeassistant/components/comelit/manifest.json +++ b/homeassistant/components/comelit/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/comelit", "iot_class": "local_polling", "loggers": ["aiocomelit"], - "requirements": ["aiocomelit==0.2.0"] + "requirements": ["aiocomelit==0.3.0"] } diff --git a/homeassistant/components/comelit/strings.json b/homeassistant/components/comelit/strings.json index 436fbfd5aec..730674e913a 100644 --- a/homeassistant/components/comelit/strings.json +++ b/homeassistant/components/comelit/strings.json @@ -11,6 +11,7 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", "pin": "[%key:common::config_flow::data::pin%]" } } diff --git a/requirements_all.txt b/requirements_all.txt index b5733231b2d..f2cda4fd70d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -216,7 +216,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.2.0 +aiocomelit==0.3.0 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bae102058d1..fdef44810c8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ aiobafi6==0.9.0 aiobotocore==2.6.0 # homeassistant.components.comelit -aiocomelit==0.2.0 +aiocomelit==0.3.0 # homeassistant.components.dhcp aiodiscover==1.5.1 diff --git a/tests/components/comelit/const.py b/tests/components/comelit/const.py index a21ddbd425a..10999b04bea 100644 --- a/tests/components/comelit/const.py +++ b/tests/components/comelit/const.py @@ -16,3 +16,5 @@ MOCK_CONFIG = { } MOCK_USER_DATA = MOCK_CONFIG[DOMAIN][CONF_DEVICES][0] + +FAKE_PIN = 5678 diff --git a/tests/components/comelit/test_config_flow.py b/tests/components/comelit/test_config_flow.py index 4e3831809cb..f2d59f46114 100644 --- a/tests/components/comelit/test_config_flow.py +++ b/tests/components/comelit/test_config_flow.py @@ -10,7 +10,7 @@ from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from .const import MOCK_USER_DATA +from .const import FAKE_PIN, MOCK_USER_DATA from tests.common import MockConfigEntry @@ -108,7 +108,7 @@ async def test_reauth_successful(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PIN: "other_fake_pin", + CONF_PIN: FAKE_PIN, }, ) await hass.async_block_till_done() @@ -150,7 +150,7 @@ async def test_reauth_not_successful(hass: HomeAssistant, side_effect, error) -> result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={ - CONF_PIN: "other_fake_pin", + CONF_PIN: FAKE_PIN, }, ) From 9db9f1b8a91cc968dc4906d9e904ea19a5c397a7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 19:22:03 +0200 Subject: [PATCH 571/968] Fix suggested UOM cannot be set for dsmr entities (#102134) * Supply dsmr entities jit on first telegram * Stale docstr Co-authored-by: Joost Lekkerkerker * Simplify tuple type --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/dsmr/sensor.py | 111 +++++++++++++------ tests/components/dsmr/test_sensor.py | 141 ++++++++++++++++++------ 2 files changed, 185 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 8159d40d2d5..e271aac4ee5 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from asyncio import CancelledError +from collections.abc import Callable from contextlib import suppress from dataclasses import dataclass from datetime import timedelta @@ -34,6 +35,10 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util import Throttle @@ -58,6 +63,8 @@ from .const import ( LOGGER, ) +EVENT_FIRST_TELEGRAM = "dsmr_first_telegram_{}" + UNIT_CONVERSION = {"m3": UnitOfVolume.CUBIC_METERS} @@ -387,17 +394,58 @@ async def async_setup_entry( ) -> None: """Set up the DSMR sensor.""" dsmr_version = entry.data[CONF_DSMR_VERSION] - entities = [ - DSMREntity(description, entry) - for description in SENSORS - if ( - description.dsmr_versions is None - or dsmr_version in description.dsmr_versions - ) - and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) - ] - async_add_entities(entities) + entities: list[DSMREntity] = [] + initialized: bool = False + add_entities_handler: Callable[..., None] | None + @callback + def init_async_add_entities(telegram: dict[str, DSMRObject]) -> None: + """Add the sensor entities after the first telegram was received.""" + nonlocal add_entities_handler + assert add_entities_handler is not None + add_entities_handler() + add_entities_handler = None + + def device_class_and_uom( + telegram: dict[str, DSMRObject], + entity_description: DSMRSensorEntityDescription, + ) -> tuple[SensorDeviceClass | None, str | None]: + """Get native unit of measurement from telegram,.""" + dsmr_object = telegram[entity_description.obis_reference] + uom: str | None = getattr(dsmr_object, "unit") or None + with suppress(ValueError): + if entity_description.device_class == SensorDeviceClass.GAS and ( + enery_uom := UnitOfEnergy(str(uom)) + ): + return (SensorDeviceClass.ENERGY, enery_uom) + if uom in UNIT_CONVERSION: + return (entity_description.device_class, UNIT_CONVERSION[uom]) + return (entity_description.device_class, uom) + + entities.extend( + [ + DSMREntity( + description, + entry, + telegram, + *device_class_and_uom( + telegram, description + ), # type: ignore[arg-type] + ) + for description in SENSORS + if ( + description.dsmr_versions is None + or dsmr_version in description.dsmr_versions + ) + and (not description.is_gas or CONF_SERIAL_ID_GAS in entry.data) + and description.obis_reference in telegram + ] + ) + async_add_entities(entities) + + add_entities_handler = async_dispatcher_connect( + hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), init_async_add_entities + ) min_time_between_updates = timedelta( seconds=entry.options.get(CONF_TIME_BETWEEN_UPDATE, DEFAULT_TIME_BETWEEN_UPDATE) ) @@ -405,10 +453,17 @@ async def async_setup_entry( @Throttle(min_time_between_updates) def update_entities_telegram(telegram: dict[str, DSMRObject] | None) -> None: """Update entities with latest telegram and trigger state update.""" + nonlocal initialized # Make all device entities aware of new telegram for entity in entities: entity.update_data(telegram) + if not initialized and telegram: + initialized = True + async_dispatcher_send( + hass, EVENT_FIRST_TELEGRAM.format(entry.entry_id), telegram + ) + # Creates an asyncio.Protocol factory for reading DSMR telegrams from # serial and calls update_entities_telegram to update entities on arrival protocol = entry.data.get(CONF_PROTOCOL, DSMR_PROTOCOL) @@ -525,6 +580,8 @@ async def async_setup_entry( @callback async def _async_stop(_: Event) -> None: + if add_entities_handler is not None: + add_entities_handler() task.cancel() # Make sure task is cancelled on shutdown (or tests complete) @@ -544,12 +601,19 @@ class DSMREntity(SensorEntity): _attr_should_poll = False def __init__( - self, entity_description: DSMRSensorEntityDescription, entry: ConfigEntry + self, + entity_description: DSMRSensorEntityDescription, + entry: ConfigEntry, + telegram: dict[str, DSMRObject], + device_class: SensorDeviceClass, + native_unit_of_measurement: str | None, ) -> None: """Initialize entity.""" self.entity_description = entity_description + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = native_unit_of_measurement self._entry = entry - self.telegram: dict[str, DSMRObject] | None = {} + self.telegram: dict[str, DSMRObject] | None = telegram device_serial = entry.data[CONF_SERIAL_ID] device_name = DEVICE_NAME_ELECTRICITY @@ -593,21 +657,6 @@ class DSMREntity(SensorEntity): """Entity is only available if there is a telegram.""" return self.telegram is not None - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of this entity.""" - device_class = super().device_class - - # Override device class for gas sensors providing energy units, like - # kWh, MWh, GJ, etc. In those cases, the class should be energy, not gas - with suppress(ValueError): - if device_class == SensorDeviceClass.GAS and UnitOfEnergy( - str(self.native_unit_of_measurement) - ): - return SensorDeviceClass.ENERGY - - return device_class - @property def native_value(self) -> StateType: """Return the state of sensor, if available, translate if needed.""" @@ -628,14 +677,6 @@ class DSMREntity(SensorEntity): return value - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - unit_of_measurement = self.get_dsmr_object_attr("unit") - if unit_of_measurement in UNIT_CONVERSION: - return UNIT_CONVERSION[unit_of_measurement] - return unit_of_measurement - @staticmethod def translate_tariff(value: str, dsmr_version: str) -> str | None: """Convert 2/1 to normal/low depending on DSMR version.""" diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 6972f0cc0cf..d734f0a93d5 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -23,8 +23,6 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, - STATE_UNAVAILABLE, - STATE_UNKNOWN, UnitOfEnergy, UnitOfPower, UnitOfVolume, @@ -84,6 +82,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + registry = er.async_get(hass) entry = registry.async_get("sensor.electricity_meter_power_consumption") @@ -94,11 +100,9 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No assert entry assert entry.unique_id == "5678_gas_meter_reading" - telegram_callback = connection_factory.call_args_list[0][0][2] - - # make sure entities have been created and return 'unavailable' state + # make sure entities are initialized power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") - assert power_consumption.state == STATE_UNAVAILABLE + assert power_consumption.state == "0.0" assert ( power_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ) @@ -107,7 +111,24 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No power_consumption.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT ) - assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert power_consumption.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "W" + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + GAS_METER_READING: MBusObject( + GAS_METER_READING, + [ + {"value": datetime.datetime.fromtimestamp(1551642214)}, + {"value": Decimal(745.701), "unit": UnitOfVolume.CUBIC_METERS}, + ], + ), + } # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) @@ -117,7 +138,7 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No # ensure entities have new state value after incoming telegram power_consumption = hass.states.get("sensor.electricity_meter_power_consumption") - assert power_consumption.state == "0.0" + assert power_consumption.state == "35.0" assert power_consumption.attributes.get("unit_of_measurement") == UnitOfPower.WATT # tariff should be translated in human readable and have no unit @@ -131,11 +152,11 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No ) assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") - assert gas_consumption.state == "745.695" + assert gas_consumption.state == "745.701" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert ( gas_consumption.attributes.get(ATTR_FRIENDLY_NAME) @@ -153,6 +174,14 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) -> None: """Test the default setup.""" + (connection_factory, transport, protocol) = dsmr_connection_fixture + + from dsmr_parser.obis_references import ( + CURRENT_ELECTRICITY_USAGE, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject + entry_data = { "port": "/dev/ttyUSB0", "dsmr_version": "2.2", @@ -160,9 +189,22 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - "reconnect_interval": 30, "serial_id": "1234", } + entry_options = { + "time_between_update": 0, + } + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + } mock_entry = MockConfigEntry( - domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options ) mock_entry.add_to_hass(hass) @@ -170,6 +212,14 @@ async def test_setup_only_energy(hass: HomeAssistant, dsmr_connection_fixture) - await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + registry = er.async_get(hass) entry = registry.async_get("sensor.electricity_meter_power_consumption") @@ -229,8 +279,8 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -239,7 +289,7 @@ async def test_v4_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -308,8 +358,8 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -318,7 +368,7 @@ async def test_v5_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -389,8 +439,8 @@ async def test_luxembourg_meter(hass: HomeAssistant, dsmr_connection_fixture) -> # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "123.456" @@ -472,8 +522,8 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -482,7 +532,7 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") @@ -537,8 +587,8 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() # tariff should be translated in human readable and have no unit active_tariff = hass.states.get("sensor.electricity_meter_active_tariff") @@ -547,7 +597,7 @@ async def test_belgian_meter_low(hass: HomeAssistant, dsmr_connection_fixture) - assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None - assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" + assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> None: @@ -597,8 +647,8 @@ async def test_swedish_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "123.456" @@ -675,8 +725,8 @@ async def test_easymeter(hass: HomeAssistant, dsmr_connection_fixture) -> None: # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser telegram_callback(telegram) - # after receiving telegram entities need to have the chance to update - await asyncio.sleep(0) + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() active_tariff = hass.states.get("sensor.electricity_meter_energy_consumption_total") assert active_tariff.state == "54184.6316" @@ -800,6 +850,12 @@ async def test_connection_errors_retry( async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: """If transport disconnects, the connection should be retried.""" + from dsmr_parser.obis_references import ( + CURRENT_ELECTRICITY_USAGE, + ELECTRICITY_ACTIVE_TARIFF, + ) + from dsmr_parser.objects import CosemObject + (connection_factory, transport, protocol) = dsmr_connection_fixture entry_data = { @@ -810,6 +866,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: "serial_id": "1234", "serial_id_gas": "5678", } + entry_options = { + "time_between_update": 0, + } + + telegram = { + CURRENT_ELECTRICITY_USAGE: CosemObject( + CURRENT_ELECTRICITY_USAGE, + [{"value": Decimal("35.0"), "unit": UnitOfPower.WATT}], + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject( + ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] + ), + } # mock waiting coroutine while connection lasts closed = asyncio.Event() @@ -823,7 +892,7 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: protocol.wait_closed = wait_closed mock_entry = MockConfigEntry( - domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data + domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data, options=entry_options ) mock_entry.add_to_hass(hass) @@ -831,11 +900,19 @@ async def test_reconnect(hass: HomeAssistant, dsmr_connection_fixture) -> None: await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to be created + await hass.async_block_till_done() + assert connection_factory.call_count == 1 state = hass.states.get("sensor.electricity_meter_power_consumption") assert state - assert state.state == STATE_UNKNOWN + assert state.state == "35.0" # indicate disconnect, release wait lock and allow reconnect to happen closed.set() @@ -897,7 +974,7 @@ async def test_gas_meter_providing_energy_reading( telegram_callback = connection_factory.call_args_list[0][0][2] telegram_callback(telegram) - await asyncio.sleep(0) + await hass.async_block_till_done() gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "123.456" From f497bcee3aa33cd81369ee2b3e1667cdb9e1e746 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 19 Oct 2023 20:04:09 +0200 Subject: [PATCH 572/968] Don't run CodeQL on PRs (#102342) --- .github/workflows/codeql.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9855f5f4cab..3d5b0cf8e57 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -7,14 +7,11 @@ on: - dev - rc - master - pull_request: - branches: - - dev schedule: - cron: "30 18 * * 4" concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: From 22c21fdc180fec24e3a45e038aba6fb685acd776 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 20:11:09 +0200 Subject: [PATCH 573/968] Do not fail MQTT setup if vacuum's configured via yaml can't be validated (#102325) Add vacuum --- .../components/mqtt/config_integration.py | 6 +- .../components/mqtt/vacuum/__init__.py | 56 ++++++++----------- .../components/mqtt/vacuum/schema_legacy.py | 17 +----- .../components/mqtt/vacuum/schema_state.py | 12 ---- tests/components/mqtt/test_legacy_vacuum.py | 8 +-- 5 files changed, 29 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index f5005390dc3..a97a577c0dc 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -27,7 +27,6 @@ from . import ( sensor as sensor_platform, switch as switch_platform, update as update_platform, - vacuum as vacuum_platform, water_heater as water_heater_platform, ) from .const import ( @@ -104,10 +103,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( cv.ensure_list, [update_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), - Platform.VACUUM.value: vol.All( - cv.ensure_list, - [vacuum_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All( cv.ensure_list, [water_heater_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 3a2586bdfd7..cbf99073ba5 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -5,30 +5,29 @@ from __future__ import annotations -import functools import logging import voluptuous as vol from homeassistant.components import vacuum from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, async_get_hass, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from ..const import DOMAIN -from ..mixins import async_setup_entry_helper +from ..mixins import async_mqtt_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, PLATFORM_SCHEMA_LEGACY_MODERN, - async_setup_entity_legacy, + MqttVacuum, ) from .schema_state import ( DISCOVERY_SCHEMA_STATE, PLATFORM_SCHEMA_STATE_MODERN, - async_setup_entity_state, + MqttStateVacuum, ) _LOGGER = logging.getLogger(__name__) @@ -39,13 +38,13 @@ MQTT_VACUUM_DOCS_URL = "https://www.home-assistant.io/integrations/vacuum.mqtt/" # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 # and will be removed with HA Core 2024.2.0 def warn_for_deprecation_legacy_schema( - hass: HomeAssistant, config: ConfigType, discovery_data: DiscoveryInfoType | None + hass: HomeAssistant, config: ConfigType, discovery: bool ) -> None: """Warn for deprecation of legacy schema.""" if config[CONF_SCHEMA] == STATE: return - key_suffix = "yaml" if discovery_data is None else "discovery" + key_suffix = "discovery" if discovery else "yaml" translation_key = f"deprecation_mqtt_legacy_vacuum_{key_suffix}" async_create_issue( hass, @@ -63,6 +62,7 @@ def warn_for_deprecation_legacy_schema( ) +@callback def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum schema.""" @@ -71,9 +71,12 @@ def validate_mqtt_vacuum_discovery(config_value: ConfigType) -> ConfigType: schemas = {LEGACY: DISCOVERY_SCHEMA_LEGACY, STATE: DISCOVERY_SCHEMA_STATE} config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) + hass = async_get_hass() + warn_for_deprecation_legacy_schema(hass, config, True) return config +@callback def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: """Validate MQTT vacuum modern schema.""" @@ -85,6 +88,10 @@ def validate_mqtt_vacuum_modern(config_value: ConfigType) -> ConfigType: STATE: PLATFORM_SCHEMA_STATE_MODERN, } config: ConfigType = schemas[config_value[CONF_SCHEMA]](config_value) + # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 + # and will be removed with HA Core 2024.2.0 + hass = async_get_hass() + warn_for_deprecation_legacy_schema(hass, config, False) return config @@ -103,28 +110,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry - ) - await async_setup_entry_helper(hass, vacuum.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT vacuum.""" - - # The legacy schema for MQTT vacuum was deprecated with HA Core 2023.8.0 - # and will be removed with HA Core 2024.2.0 - warn_for_deprecation_legacy_schema(hass, config, discovery_data) - setup_entity = { - LEGACY: async_setup_entity_legacy, - STATE: async_setup_entity_state, - } - await setup_entity[config[CONF_SCHEMA]]( - hass, config, async_add_entities, config_entry, discovery_data + await async_mqtt_entry_helper( + hass, + config_entry, + None, + vacuum.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, + {"legacy": MqttVacuum, "state": MqttStateVacuum}, ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index aee71cc6690..ab13de59ede 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -17,14 +17,12 @@ from homeassistant.components.vacuum import ( VacuumEntity, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.json import json_dumps -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -201,17 +199,6 @@ _COMMANDS = { } -async def async_setup_entity_legacy( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a MQTT Vacuum Legacy.""" - async_add_entities([MqttVacuum(hass, config, config_entry, discovery_data)]) - - class MqttVacuum(MqttEntity, VacuumEntity): """Representation of a MQTT-controlled legacy vacuum.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 425202adea2..a51429f0c05 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -23,7 +23,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads_object @@ -156,17 +155,6 @@ PLATFORM_SCHEMA_STATE_MODERN = ( DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) -async def async_setup_entity_state( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None, -) -> None: - """Set up a State MQTT Vacuum.""" - async_add_entities([MqttStateVacuum(hass, config, config_entry, discovery_data)]) - - class MqttStateVacuum(MqttEntity, StateVacuumEntity): """Representation of a MQTT-controlled state vacuum.""" diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index c7d17ed47a0..61a27c287ac 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -642,12 +642,8 @@ async def test_missing_templates( caplog: pytest.LogCaptureFixture, ) -> None: """Test to make sure missing template is not allowed.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: some but not all values in the same group of inclusion" - in caplog.text - ) + assert await mqtt_mock_entry() + assert "some but not all values in the same group of inclusion" in caplog.text @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG_2]) From b57af4e404f0c9b9b1cc8cf31c0ae4cf26c45f10 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 08:35:58 -1000 Subject: [PATCH 574/968] Remove update_before_add from roomba (#102337) --- homeassistant/components/roomba/binary_sensor.py | 2 +- homeassistant/components/roomba/sensor.py | 1 - homeassistant/components/roomba/vacuum.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index cd37e089c9f..421a563dca9 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -21,7 +21,7 @@ async def async_setup_entry( status = roomba_reported_state(roomba).get("bin", {}) if "full" in status: roomba_vac = RoombaBinStatus(roomba, blid) - async_add_entities([roomba_vac], True) + async_add_entities([roomba_vac]) class RoombaBinStatus(IRobotEntity, BinarySensorEntity): diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 7c34169eb85..c1fb71b1b37 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -45,7 +45,6 @@ async def async_setup_entry( roomba_failed_missions, roomba_scrubs_count, ], - True, ) diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 4333f926ba5..15ac0c7b90b 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -36,4 +36,4 @@ async def async_setup_entry( constructor = RoombaVacuum roomba_vac = constructor(roomba, blid) - async_add_entities([roomba_vac], True) + async_add_entities([roomba_vac]) From e26a2596afd848a8ebc72efcbbf5fa1583282998 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 21:14:45 +0200 Subject: [PATCH 575/968] Do not fail MQTT setup if climate's configured via yaml can't be validated (#102303) Add climate --- homeassistant/components/mqtt/climate.py | 29 +++++++--------- .../components/mqtt/config_integration.py | 6 +--- tests/components/mqtt/test_climate.py | 33 +++++++++---------- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 77f28e1b5ca..4437f2a6270 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -3,7 +3,6 @@ from __future__ import annotations from abc import ABC, abstractmethod from collections.abc import Callable -import functools import logging from typing import Any @@ -47,7 +46,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from . import subscription @@ -85,7 +84,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -399,22 +398,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT climate device through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT climate through YAML and through MQTT discovery.""" + await async_mqtt_entry_helper( + hass, + config_entry, + MqttClimate, + climate.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, climate.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT climate devices.""" - async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) class MqttTemperatureControlEntity(MqttEntity, ABC): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index a97a577c0dc..e05056fdb57 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -16,7 +16,6 @@ from homeassistant.helpers import config_validation as cv from . import ( button as button_platform, - climate as climate_platform, cover as cover_platform, event as event_platform, humidifier as humidifier_platform, @@ -52,10 +51,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [button_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), - Platform.CLIMATE.value: vol.All( - cv.ensure_list, - [climate_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All( cv.ensure_list, [cover_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 9c0adbe2adf..89eaf87fb3a 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -138,9 +138,8 @@ async def test_preset_none_in_preset_modes( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the preset mode payload reset configuration.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: not a valid value" in caplog.text + assert await mqtt_mock_entry() + assert "not a valid value" in caplog.text @pytest.mark.parametrize( @@ -2448,11 +2447,11 @@ async def test_publishing_with_custom_encoding( @pytest.mark.parametrize( ("hass_config", "valid"), [ - ( + ( # test_valid_humidity_min_max { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_valid_humidity_min_max", + "name": "test", "min_humidity": 20, "max_humidity": 80, }, @@ -2460,11 +2459,11 @@ async def test_publishing_with_custom_encoding( }, True, ), - ( + ( # test_invalid_humidity_min_max_1 { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_min_max_1", + "name": "test", "min_humidity": 0, "max_humidity": 101, }, @@ -2472,11 +2471,11 @@ async def test_publishing_with_custom_encoding( }, False, ), - ( + ( # test_invalid_humidity_min_max_2 { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_min_max_2", + "name": "test", "max_humidity": 20, "min_humidity": 40, }, @@ -2484,11 +2483,11 @@ async def test_publishing_with_custom_encoding( }, False, ), - ( + ( # test_valid_humidity_state { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_valid_humidity_state", + "name": "test", "target_humidity_state_topic": "humidity-state", "target_humidity_command_topic": "humidity-command", }, @@ -2496,11 +2495,11 @@ async def test_publishing_with_custom_encoding( }, True, ), - ( + ( # test_invalid_humidity_state { mqtt.DOMAIN: { climate.DOMAIN: { - "name": "test_invalid_humidity_state", + "name": "test", "target_humidity_state_topic": "humidity-state", }, } @@ -2515,11 +2514,9 @@ async def test_humidity_configuration_validity( valid: bool, ) -> None: """Test the validity of humidity configurations.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() + state = hass.states.get("climate.test") + assert (state is not None) == valid async def test_reloadable( From 063d74c35dfdb5de0ee0f3809ec8f1777399557d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 19 Oct 2023 21:37:01 +0200 Subject: [PATCH 576/968] Use entity descriptions in Roomba (#102323) Co-authored-by: J. Nick Koston --- .../components/roomba/irobot_base.py | 16 +- homeassistant/components/roomba/sensor.py | 295 +++++++----------- homeassistant/components/roomba/strings.json | 26 ++ 3 files changed, 154 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 561effeb6c5..451cdfc4c46 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -101,20 +101,23 @@ class IRobotEntity(Entity): ) @property - def _battery_level(self): + def battery_level(self): """Return the battery level of the vacuum cleaner.""" return self.vacuum_state.get("batPct") @property - def _run_stats(self): + def run_stats(self): + """Return the run stats.""" return self.vacuum_state.get("bbrun") @property - def _mission_stats(self): + def mission_stats(self): + """Return the mission stats.""" return self.vacuum_state.get("bbmssn") @property - def _battery_stats(self): + def battery_stats(self): + """Return the battery stats.""" return self.vacuum_state.get("bbchg3") @property @@ -158,11 +161,6 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): super().__init__(roomba, blid) self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - @property - def battery_level(self): - """Return the battery level of the vacuum cleaner.""" - return self._battery_level - @property def state(self): """Return the state of the vacuum cleaner.""" diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index c1fb71b1b37..6457c35f6d7 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -1,18 +1,121 @@ -"""Sensor for checking the battery level of Roomba.""" +"""Sensor platform for Roomba.""" +from collections.abc import Callable +from dataclasses import dataclass + +from roombapy import Roomba + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import BLID, DOMAIN, ROOMBA_SESSION from .irobot_base import IRobotEntity +@dataclass +class RoombaSensorEntityDescriptionMixin: + """Mixin for describing Roomba data.""" + + value_fn: Callable[[IRobotEntity], StateType] + + +@dataclass +class RoombaSensorEntityDescription( + SensorEntityDescription, RoombaSensorEntityDescriptionMixin +): + """Immutable class for describing Roomba data.""" + + +SENSORS: list[RoombaSensorEntityDescription] = [ + RoombaSensorEntityDescription( + key="battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.battery_level, + ), + RoombaSensorEntityDescription( + key="battery_cycles", + translation_key="battery_cycles", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:counter", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.battery_stats.get("nLithChrg") + or self.battery_stats.get("nNimhChrg"), + ), + RoombaSensorEntityDescription( + key="total_cleaning_time", + translation_key="total_cleaning_time", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.HOURS, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.run_stats.get("hr"), + ), + RoombaSensorEntityDescription( + key="average_mission_time", + translation_key="average_mission_time", + icon="mdi:clock", + native_unit_of_measurement=UnitOfTime.MINUTES, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("aMssnM"), + ), + RoombaSensorEntityDescription( + key="total_missions", + translation_key="total_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssn"), + ), + RoombaSensorEntityDescription( + key="successful_missions", + translation_key="successful_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnOk"), + ), + RoombaSensorEntityDescription( + key="canceled_missions", + translation_key="canceled_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnC"), + ), + RoombaSensorEntityDescription( + key="failed_missions", + translation_key="failed_missions", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Missions", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.mission_stats.get("nMssnF"), + ), + RoombaSensorEntityDescription( + key="scrubs_count", + translation_key="scrubs", + icon="mdi:counter", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement="Scrubs", + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.run_stats.get("nScrubs"), + entity_registry_enabled_default=False, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -23,188 +126,32 @@ async def async_setup_entry( roomba = domain_data[ROOMBA_SESSION] blid = domain_data[BLID] - roomba_vac = RoombaBattery(roomba, blid) - roomba_battery_cycles = BatteryCycles(roomba, blid) - roomba_cleaning_time = CleaningTime(roomba, blid) - roomba_average_mission_time = AverageMissionTime(roomba, blid) - roomba_total_missions = MissionSensor(roomba, blid, "total", "nMssn") - roomba_success_missions = MissionSensor(roomba, blid, "successful", "nMssnOk") - roomba_canceled_missions = MissionSensor(roomba, blid, "canceled", "nMssnC") - roomba_failed_missions = MissionSensor(roomba, blid, "failed", "nMssnF") - roomba_scrubs_count = ScrubsCount(roomba, blid) - async_add_entities( - [ - roomba_vac, - roomba_battery_cycles, - roomba_cleaning_time, - roomba_average_mission_time, - roomba_total_missions, - roomba_success_missions, - roomba_canceled_missions, - roomba_failed_missions, - roomba_scrubs_count, - ], + RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS ) -class RoombaBattery(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" +class RoombaSensor(IRobotEntity, SensorEntity): + """Roomba sensor.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE + entity_description: RoombaSensorEntityDescription - @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"battery_{self._blid}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._battery_level - - -class BatteryCycles(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:counter" - - @property - def name(self): - """Return the name of the sensor.""" - return "Battery cycles" - - @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"battery_cycles_{self._blid}" - - @property - def state_class(self): - """Return the state class of this entity, from STATE_CLASSES, if any.""" - return SensorStateClass.MEASUREMENT - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._battery_stats.get("nLithChrg") or self._battery_stats.get( - "nNimhChrg" - ) - - -class CleaningTime(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:clock" - _attr_native_unit_of_measurement = UnitOfTime.HOURS - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} cleaning time total" - - @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"total_cleaning_time_{self._blid}" - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._run_stats.get("hr") - - -class AverageMissionTime(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:clock" - - @property - def name(self): - """Return the name of the sensor.""" - return "Average mission time" - - @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"average_mission_time_{self._blid}" - - @property - def native_unit_of_measurement(self): - """Return the unit_of_measurement of the device.""" - return UnitOfTime.MINUTES - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._mission_stats.get("aMssnM") - - -class MissionSensor(IRobotEntity, SensorEntity): - """Class to hold the Roomba missions info.""" - - def __init__(self, roomba, blid, mission_type, mission_value_string): - """Initialise iRobot sensor with mission details.""" + def __init__( + self, + roomba: Roomba, + blid: str, + entity_description: RoombaSensorEntityDescription, + ) -> None: + """Initialize Roomba sensor.""" super().__init__(roomba, blid) - self._mission_type = mission_type - self._mission_value_string = mission_value_string - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:counter" + self.entity_description = entity_description @property - def name(self): - """Return the name of the sensor.""" - return f"Missions {self._mission_type}" + def unique_id(self) -> str: + """Return a unique ID.""" + return f"{self.entity_description.key}_{self._blid}" @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"{self._mission_type}_missions_{self._blid}" - - @property - def state_class(self): - """Return the state class of this entity, from STATE_CLASSES, if any.""" - return SensorStateClass.MEASUREMENT - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the sensor.""" - return self._mission_stats.get(self._mission_value_string) - - -class ScrubsCount(IRobotEntity, SensorEntity): - """Class to hold Roomba Sensor basic info.""" - - _attr_entity_category = EntityCategory.DIAGNOSTIC - _attr_icon = "mdi:counter" - - @property - def name(self): - """Return the name of the sensor.""" - return "Scrubs count" - - @property - def unique_id(self): - """Return the ID of this sensor.""" - return f"scrubs_count_{self._blid}" - - @property - def state_class(self): - """Return the state class of this entity, from STATE_CLASSES, if any.""" - return SensorStateClass.MEASUREMENT - - @property - def entity_registry_enabled_default(self): - """Disable sensor by default.""" - return False - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._run_stats.get("nScrubs") + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 206e8c5bae0..f1816d58613 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -53,6 +53,32 @@ "bin_full": { "name": "Bin full" } + }, + "sensor": { + "battery_cycles": { + "name": "Battery cycles" + }, + "total_cleaning_time": { + "name": "Total cleaning time" + }, + "average_mission_time": { + "name": "Average mission time" + }, + "total_missions": { + "name": "Total missions" + }, + "successful_missions": { + "name": "Successful missions" + }, + "canceled_missions": { + "name": "Canceled missions" + }, + "failed_missions": { + "name": "Failed missions" + }, + "scrubs_count": { + "name": "Scrubs" + } } } } From 96b450ad393d2facad5e2159d67a8fdd3f45682b Mon Sep 17 00:00:00 2001 From: thatso <40745263+thatso@users.noreply.github.com> Date: Thu, 19 Oct 2023 21:38:15 +0200 Subject: [PATCH 577/968] Improve wording in NUT (#102353) --- homeassistant/components/nut/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 3041ac38726..c897542e666 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -28,7 +28,7 @@ STATE_TYPES = { "OB": "On Battery", "LB": "Low Battery", "HB": "High Battery", - "RB": "Battery Needs Replaced", + "RB": "Battery Needs Replacement", "CHRG": "Battery Charging", "DISCHRG": "Battery Discharging", "BYPASS": "Bypass Active", From 5c422c61e9f726149b278d3f2b7f42f8b5587440 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 09:43:48 -1000 Subject: [PATCH 578/968] Improve typing in roomba integration (#102350) --- homeassistant/components/roomba/__init__.py | 46 ++++++++----------- .../components/roomba/binary_sensor.py | 9 ++-- homeassistant/components/roomba/const.py | 2 - homeassistant/components/roomba/models.py | 14 ++++++ homeassistant/components/roomba/sensor.py | 9 ++-- homeassistant/components/roomba/vacuum.py | 9 ++-- 6 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 homeassistant/components/roomba/models.py diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py index 85dbbe14cdc..586e2a5f062 100644 --- a/homeassistant/components/roomba/__init__.py +++ b/homeassistant/components/roomba/__init__.py @@ -1,9 +1,11 @@ """The roomba component.""" import asyncio +import contextlib from functools import partial import logging +from typing import Any -from roombapy import RoombaConnectionError, RoombaFactory +from roombapy import Roomba, RoombaConnectionError, RoombaFactory from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry @@ -16,15 +18,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import ( - BLID, - CANCEL_STOP, - CONF_BLID, - CONF_CONTINUOUS, - DOMAIN, - PLATFORMS, - ROOMBA_SESSION, -) +from .const import CONF_BLID, CONF_CONTINUOUS, DOMAIN, PLATFORMS, ROOMBA_SESSION +from .models import RoombaData _LOGGER = logging.getLogger(__name__) @@ -62,16 +57,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def _async_disconnect_roomba(event): await async_disconnect_or_timeout(hass, roomba) - cancel_stop = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_disconnect_roomba) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = { - ROOMBA_SESSION: roomba, - BLID: config_entry.data[CONF_BLID], - CANCEL_STOP: cancel_stop, - } + domain_data = RoombaData(roomba, config_entry.data[CONF_BLID]) + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = domain_data await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) @@ -81,7 +72,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True -async def async_connect_or_timeout(hass, roomba): +async def async_connect_or_timeout( + hass: HomeAssistant, roomba: Roomba +) -> dict[str, Any]: """Connect to vacuum.""" try: name = None @@ -106,12 +99,12 @@ async def async_connect_or_timeout(hass, roomba): return {ROOMBA_SESSION: roomba, CONF_NAME: name} -async def async_disconnect_or_timeout(hass, roomba): +async def async_disconnect_or_timeout(hass: HomeAssistant, roomba: Roomba) -> None: """Disconnect to vacuum.""" _LOGGER.debug("Disconnect vacuum") - async with asyncio.timeout(3): - await hass.async_add_executor_job(roomba.disconnect) - return True + with contextlib.suppress(asyncio.TimeoutError): + async with asyncio.timeout(3): + await hass.async_add_executor_job(roomba.disconnect) async def async_update_options(hass: HomeAssistant, config_entry: ConfigEntry) -> None: @@ -125,15 +118,14 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) if unload_ok: - domain_data = hass.data[DOMAIN][config_entry.entry_id] - domain_data[CANCEL_STOP]() - await async_disconnect_or_timeout(hass, roomba=domain_data[ROOMBA_SESSION]) + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + await async_disconnect_or_timeout(hass, roomba=domain_data.roomba) hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok -def roomba_reported_state(roomba): +def roomba_reported_state(roomba: Roomba) -> dict[str, Any]: """Roomba report.""" return roomba.master_state.get("state", {}).get("reported", {}) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index 421a563dca9..007d803fbf4 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -5,8 +5,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotEntity +from .models import RoombaData async def async_setup_entry( @@ -15,9 +16,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid status = roomba_reported_state(roomba).get("bin", {}) if "full" in status: roomba_vac = RoombaBinStatus(roomba, blid) diff --git a/homeassistant/components/roomba/const.py b/homeassistant/components/roomba/const.py index ae872e0540c..151d3bfb68e 100644 --- a/homeassistant/components/roomba/const.py +++ b/homeassistant/components/roomba/const.py @@ -10,5 +10,3 @@ DEFAULT_CERT = "/etc/ssl/certs/ca-certificates.crt" DEFAULT_CONTINUOUS = True DEFAULT_DELAY = 1 ROOMBA_SESSION = "roomba_session" -BLID = "blid_key" -CANCEL_STOP = "cancel_stop" diff --git a/homeassistant/components/roomba/models.py b/homeassistant/components/roomba/models.py new file mode 100644 index 00000000000..87610bed1ae --- /dev/null +++ b/homeassistant/components/roomba/models.py @@ -0,0 +1,14 @@ +"""The roomba integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from roombapy import Roomba + + +@dataclass +class RoombaData: + """Data for the roomba integration.""" + + roomba: Roomba + blid: str diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index 6457c35f6d7..3b2b34af67b 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -16,8 +16,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotEntity +from .models import RoombaData @dataclass @@ -122,9 +123,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid async_add_entities( RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 15ac0c7b90b..b6c0e893b1c 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -7,8 +7,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import roomba_reported_state from .braava import BraavaJet -from .const import BLID, DOMAIN, ROOMBA_SESSION +from .const import DOMAIN from .irobot_base import IRobotVacuum +from .models import RoombaData from .roomba import RoombaVacuum, RoombaVacuumCarpetBoost @@ -18,9 +19,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the iRobot Roomba vacuum cleaner.""" - domain_data = hass.data[DOMAIN][config_entry.entry_id] - roomba = domain_data[ROOMBA_SESSION] - blid = domain_data[BLID] + domain_data: RoombaData = hass.data[DOMAIN][config_entry.entry_id] + roomba = domain_data.roomba + blid = domain_data.blid # Get the capabilities of our unit state = roomba_reported_state(roomba) From e6e2aa0ea0799f50bd2c029531facc985ab829d8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 19 Oct 2023 21:50:14 +0200 Subject: [PATCH 579/968] Import Comelit state from library (#102356) --- homeassistant/components/comelit/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 30ef5dc393b..89271f142f5 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiocomelit import ComelitSerialBridgeObject -from aiocomelit.const import IRRIGATION, OTHER +from aiocomelit.const import IRRIGATION, OTHER, STATE_OFF, STATE_ON from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, STATE_OFF, STATE_ON +from .const import DOMAIN from .coordinator import ComelitSerialBridge From 54c80491e39e784ee37e7178e6187fb436727a31 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 22:10:45 +0200 Subject: [PATCH 580/968] Do not fail MQTT setup if select's configured via yaml can't be validated (#102318) Add select --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/select.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index e05056fdb57..ec0bb354cf5 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -22,7 +22,6 @@ from . import ( lawn_mower as lawn_mower_platform, lock as lock_platform, number as number_platform, - select as select_platform, sensor as sensor_platform, switch as switch_platform, update as update_platform, @@ -81,10 +80,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), - Platform.SELECT.value: vol.All( - cv.ensure_list, - [select_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), Platform.SENSOR.value: vol.All( cv.ensure_list, [sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 03cd529fdd0..6c391232072 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import voluptuous as vol @@ -15,7 +14,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -31,7 +30,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -73,21 +72,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttSelect, + select.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, select.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT select.""" - async_add_entities([MqttSelect(hass, config, config_entry, discovery_data)]) class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): From ec1b3fe6fba9af29ef40c0e2424d16fe0724d49d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 23:04:35 +0200 Subject: [PATCH 581/968] Do not fail MQTT setup if switches configured via yaml can't be validated (#102320) Add switch --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/switch.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index ec0bb354cf5..2d932c75481 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -23,7 +23,6 @@ from . import ( lock as lock_platform, number as number_platform, sensor as sensor_platform, - switch as switch_platform, update as update_platform, water_heater as water_heater_platform, ) @@ -86,10 +85,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), - Platform.SWITCH.value: vol.All( - cv.ensure_list, - [switch_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), Platform.UPDATE.value: vol.All( cv.ensure_list, diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index d4e8f2609d9..7221d02611e 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools from typing import Any import voluptuous as vol @@ -24,7 +23,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.service_info.mqtt import ReceivePayloadType -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -40,7 +39,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -72,21 +71,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttSwitch, + switch.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, switch.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT switch.""" - async_add_entities([MqttSwitch(hass, config, config_entry, discovery_data)]) class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): From 6baa8082d572de84fccb5b5ee3697882fa25fa05 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 11:12:56 -1000 Subject: [PATCH 582/968] Bump aiohomekit to 3.0.6 (#102341) --- homeassistant/components/homekit_controller/entity.py | 5 ++++- homeassistant/components/homekit_controller/manifest.json | 2 +- homeassistant/components/homekit_controller/media_player.py | 1 + homeassistant/components/homekit_controller/sensor.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index f8566f10b0d..796f227e0cc 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -36,6 +36,7 @@ class HomeKitEntity(Entity): pollable_characteristics: list[tuple[int, int]] watchable_characteristics: list[tuple[int, int]] all_characteristics: set[tuple[int, int]] + accessory_info: Service def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: """Initialise a generic HomeKit device.""" @@ -144,9 +145,11 @@ class HomeKitEntity(Entity): accessory = self._accessory self.accessory = accessory.entity_map.aid(self._aid) self.service = self.accessory.services.iid(self._iid) - self.accessory_info = self.accessory.services.first( + accessory_info = self.accessory.services.first( service_type=ServicesTypes.ACCESSORY_INFORMATION ) + assert accessory_info + self.accessory_info = accessory_info # If we re-setup, we need to make sure we make new # lists since we passed them to the connection before # and we do not want to inadvertently modify the old diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5687cd4dba3..9c989563b6a 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.5"], + "requirements": ["aiohomekit==3.0.6"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 1efa33429b1..90d1ba754f2 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -159,6 +159,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerEntity): characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier}, parent_service=this_tv, ) + assert input_source char = input_source[CharacteristicsTypes.CONFIGURED_NAME] return char.value diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 0f481c5c7ee..1f17d32f912 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -667,6 +667,7 @@ async def async_setup_entry( accessory_info = accessory.services.first( service_type=ServicesTypes.ACCESSORY_INFORMATION ) + assert accessory_info info = {"aid": accessory.aid, "iid": accessory_info.iid} entity = RSSISensor(conn, info) conn.async_migrate_unique_id( diff --git a/requirements_all.txt b/requirements_all.txt index f2cda4fd70d..6398afafdca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.5 +aiohomekit==3.0.6 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fdef44810c8..a19c88b895e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.5 +aiohomekit==3.0.6 # homeassistant.components.emulated_hue # homeassistant.components.http From f4e7c5aed35503254808585725b73e85861a848d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 19 Oct 2023 23:29:49 +0200 Subject: [PATCH 583/968] Do not fail MQTT setup if humidifiers configured via yaml can't be validated (#102312) Add humidifier --- .../components/mqtt/config_integration.py | 6 +- homeassistant/components/mqtt/humidifier.py | 27 ++++----- tests/components/mqtt/test_humidifier.py | 58 +++++++++---------- 3 files changed, 37 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 2d932c75481..a691197092d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -18,7 +18,6 @@ from . import ( button as button_platform, cover as cover_platform, event as event_platform, - humidifier as humidifier_platform, lawn_mower as lawn_mower_platform, lock as lock_platform, number as number_platform, @@ -60,10 +59,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), - Platform.HUMIDIFIER.value: vol.All( - cv.ensure_list, - [humidifier_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.HUMIDIFIER.value: vol.All(cv.ensure_list, [dict]), Platform.IMAGE.value: vol.All(cv.ensure_list, [dict]), Platform.LAWN_MOWER.value: vol.All( cv.ensure_list, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 05929ee904a..1e56ba1649a 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any @@ -33,7 +32,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -55,7 +54,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -192,21 +191,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttHumidifier, + humidifier.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, humidifier.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT humidifier.""" - async_add_entities([MqttHumidifier(hass, config, config_entry, discovery_data)]) class MqttHumidifier(MqttEntity, HumidifierEntity): diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index 4d2637a264f..69e85e51d73 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -142,9 +142,8 @@ async def test_fail_setup_if_no_command_topic( caplog: pytest.LogCaptureFixture, ) -> None: """Test if command fails with command topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: required key not provided" in caplog.text + assert await mqtt_mock_entry() + assert "required key not provided" in caplog.text @pytest.mark.parametrize( @@ -934,11 +933,11 @@ async def test_attributes( @pytest.mark.parametrize( ("hass_config", "valid"), [ - ( + ( # test valid case 1 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_1", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", } @@ -946,11 +945,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 2 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_2", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "humidifier", @@ -959,11 +958,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 3 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_3", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "dehumidifier", @@ -972,11 +971,11 @@ async def test_attributes( }, True, ), - ( + ( # test valid case 4 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_valid_4", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": None, @@ -985,11 +984,11 @@ async def test_attributes( }, True, ), - ( + ( # test invalid device_class { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_device_class", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "device_class": "notsupporedSpeci@l", @@ -998,11 +997,11 @@ async def test_attributes( }, False, ), - ( + ( # test mode_command_topic without modes { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_mode_command_without_modes", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "mode_command_topic": "mode-command-topic", @@ -1011,11 +1010,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid humidity min max case 1 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_humidity_min_max_1", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "min_humidity": 0, @@ -1025,11 +1024,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid humidity min max case 2 { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_humidity_min_max_2", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "max_humidity": 20, @@ -1039,11 +1038,11 @@ async def test_attributes( }, False, ), - ( + ( # test invalid mode, is reset payload { mqtt.DOMAIN: { humidifier.DOMAIN: { - "name": "test_invalid_mode_is_reset", + "name": "test", "command_topic": "command-topic", "target_humidity_command_topic": "humidity-command-topic", "mode_command_topic": "mode-command-topic", @@ -1061,11 +1060,9 @@ async def test_validity_configurations( valid: bool, ) -> None: """Test validity of configurations.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + await mqtt_mock_entry() + state = hass.states.get("humidifier.test") + assert (state is not None) == valid @pytest.mark.parametrize( @@ -1167,14 +1164,11 @@ async def test_supported_features( features: humidifier.HumidifierEntityFeature | None, ) -> None: """Test supported features.""" + await mqtt_mock_entry() + state = hass.states.get(f"humidifier.{name}") + assert (state is not None) == success if success: - await mqtt_mock_entry() - - state = hass.states.get(f"humidifier.{name}") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == features - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() @pytest.mark.parametrize("hass_config", [DEFAULT_CONFIG]) From fd435a54165e01215e090f9e1d02b074edb80ef4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 20 Oct 2023 00:08:26 +0200 Subject: [PATCH 584/968] Address MyStrom late review (#102306) * Address MyStrom late review * Address MyStrom late review --- homeassistant/components/mystrom/__init__.py | 3 +-- homeassistant/components/mystrom/sensor.py | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py index 96bc49ca853..3b033e3338c 100644 --- a/homeassistant/components/mystrom/__init__.py +++ b/homeassistant/components/mystrom/__init__.py @@ -16,7 +16,7 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .models import MyStromData -PLATFORMS_PLUGS = [Platform.SWITCH, Platform.SENSOR] +PLATFORMS_PLUGS = [Platform.SENSOR, Platform.SWITCH] PLATFORMS_BULB = [Platform.LIGHT] _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,6 @@ def _get_mystrom_switch(host: str) -> MyStromSwitch: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up myStrom from a config entry.""" host = entry.data[CONF_HOST] - device = None try: info = await pymystrom.get_device_info(host) except MyStromConnectionError as err: diff --git a/homeassistant/components/mystrom/sensor.py b/homeassistant/components/mystrom/sensor.py index 982528bd97c..606a6275acf 100644 --- a/homeassistant/components/mystrom/sensor.py +++ b/homeassistant/components/mystrom/sensor.py @@ -64,7 +64,6 @@ class MyStromSwitchSensor(SensorEntity): """Representation of the consumption or temperature of a myStrom switch/plug.""" entity_description: MyStromSwitchSensorEntityDescription - device: MyStromSwitch _attr_has_entity_name = True From 5264cdf3821372b3e99d3f514cab5128b08d020d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 01:13:35 +0200 Subject: [PATCH 585/968] Do not fail MQTT setup if locks configured via yaml can't be validated (#102315) Add lock --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/lock.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index a691197092d..4fb53db1031 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -19,7 +19,6 @@ from . import ( cover as cover_platform, event as event_platform, lawn_mower as lawn_mower_platform, - lock as lock_platform, number as number_platform, sensor as sensor_platform, update as update_platform, @@ -66,10 +65,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), - Platform.LOCK.value: vol.All( - cv.ensure_list, - [lock_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), Platform.NUMBER.value: vol.All( cv.ensure_list, [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 9a0ce2077f3..f6177d94410 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import re from typing import Any @@ -20,7 +19,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, TemplateVarsType +from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import subscription from .config import MQTT_RW_SCHEMA @@ -37,7 +36,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -113,21 +112,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttLock, + lock.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, lock.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Lock platform.""" - async_add_entities([MqttLock(hass, config, config_entry, discovery_data)]) class MqttLock(MqttEntity, LockEntity): From e6d9f89991bb6acc917c0e586a65120f1224ae68 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 08:10:46 +0200 Subject: [PATCH 586/968] Remove dev API docs from repository (#102274) --- .gitignore | 3 - .prettierignore | 1 - .readthedocs.yml | 14 - docs/Makefile | 230 ------------- docs/build/.empty | 0 docs/make.bat | 281 ---------------- docs/screenshot-integrations.png | Bin 178429 -> 0 bytes docs/screenshots.png | Bin 231797 -> 0 bytes docs/source/_ext/edit_on_github.py | 45 --- docs/source/_static/favicon.ico | Bin 17957 -> 0 bytes docs/source/_static/logo-apple.png | Bin 13441 -> 0 bytes docs/source/_static/logo.png | Bin 13472 -> 0 bytes docs/source/_templates/links.html | 6 - docs/source/_templates/sourcelink.html | 13 - docs/source/api/auth.rst | 29 -- docs/source/api/bootstrap.rst | 7 - docs/source/api/components.rst | 170 ---------- docs/source/api/config_entries.rst | 7 - docs/source/api/core.rst | 7 - docs/source/api/data_entry_flow.rst | 7 - docs/source/api/exceptions.rst | 7 - docs/source/api/helpers.rst | 327 ------------------ docs/source/api/loader.rst | 7 - docs/source/api/util.rst | 119 ------- docs/source/conf.py | 438 ------------------------- docs/source/index.rst | 22 -- requirements_docs.txt | 3 - 27 files changed, 1743 deletions(-) delete mode 100644 .readthedocs.yml delete mode 100644 docs/Makefile delete mode 100644 docs/build/.empty delete mode 100644 docs/make.bat delete mode 100644 docs/screenshot-integrations.png delete mode 100644 docs/screenshots.png delete mode 100644 docs/source/_ext/edit_on_github.py delete mode 100644 docs/source/_static/favicon.ico delete mode 100644 docs/source/_static/logo-apple.png delete mode 100644 docs/source/_static/logo.png delete mode 100644 docs/source/_templates/links.html delete mode 100644 docs/source/_templates/sourcelink.html delete mode 100644 docs/source/api/auth.rst delete mode 100644 docs/source/api/bootstrap.rst delete mode 100644 docs/source/api/components.rst delete mode 100644 docs/source/api/config_entries.rst delete mode 100644 docs/source/api/core.rst delete mode 100644 docs/source/api/data_entry_flow.rst delete mode 100644 docs/source/api/exceptions.rst delete mode 100644 docs/source/api/helpers.rst delete mode 100644 docs/source/api/loader.rst delete mode 100644 docs/source/api/util.rst delete mode 100644 docs/source/conf.py delete mode 100644 docs/source/index.rst delete mode 100644 requirements_docs.txt diff --git a/.gitignore b/.gitignore index ff20c088eb2..8a4154e4769 100644 --- a/.gitignore +++ b/.gitignore @@ -111,9 +111,6 @@ virtualization/vagrant/config !.vscode/tasks.json .env -# Built docs -docs/build - # Windows Explorer desktop.ini /home-assistant.pyproj diff --git a/.prettierignore b/.prettierignore index aab23e23078..07637a380c5 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,7 +1,6 @@ *.md .strict-typing azure-*.yml -docs/source/_templates/* homeassistant/components/*/translations/*.json homeassistant/generated/* tests/components/lidarr/fixtures/initialize.js diff --git a/.readthedocs.yml b/.readthedocs.yml deleted file mode 100644 index 1a91abd9a99..00000000000 --- a/.readthedocs.yml +++ /dev/null @@ -1,14 +0,0 @@ -# .readthedocs.yml - -version: 2 - -build: - os: ubuntu-20.04 - tools: - python: "3.9" - -python: - install: - - method: setuptools - path: . - - requirements: requirements_docs.txt diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 69893c43847..00000000000 --- a/docs/Makefile +++ /dev/null @@ -1,230 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source - -.PHONY: help -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " livehtml to make standalone HTML files via sphinx-autobuild" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " applehelp to make an Apple Help Book" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " epub3 to make an epub3" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " xml to make Docutils-native XML files" - @echo " pseudoxml to make pseudoxml-XML files for display purposes" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - @echo " coverage to run coverage check of the documentation (if enabled)" - @echo " dummy to check syntax errors of document sources" - -.PHONY: clean -clean: - rm -rf $(BUILDDIR)/* - -.PHONY: html -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -.PHONY: livehtml -livehtml: - sphinx-autobuild -z ../homeassistant/ --port 0 -B -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - -.PHONY: dirhtml -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -.PHONY: singlehtml -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -.PHONY: pickle -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -.PHONY: json -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -.PHONY: htmlhelp -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -.PHONY: qthelp -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Home-Assistant.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Home-Assistant.qhc" - -.PHONY: applehelp -applehelp: - $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp - @echo - @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." - @echo "N.B. You won't be able to view it unless you put it in" \ - "~/Library/Documentation/Help or install it in your application" \ - "bundle." - -.PHONY: devhelp -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/Home-Assistant" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Home-Assistant" - @echo "# devhelp" - -.PHONY: epub -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -.PHONY: epub3 -epub3: - $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 - @echo - @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." - -.PHONY: latex -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -.PHONY: latexpdf -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: latexpdfja -latexpdfja: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through platex and dvipdfmx..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -.PHONY: text -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -.PHONY: man -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -.PHONY: texinfo -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -.PHONY: info -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -.PHONY: gettext -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -.PHONY: changes -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -.PHONY: linkcheck -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -.PHONY: doctest -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." - -.PHONY: coverage -coverage: - $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage - @echo "Testing of coverage in the sources finished, look at the " \ - "results in $(BUILDDIR)/coverage/python.txt." - -.PHONY: xml -xml: - $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml - @echo - @echo "Build finished. The XML files are in $(BUILDDIR)/xml." - -.PHONY: pseudoxml -pseudoxml: - $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml - @echo - @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." - -.PHONY: dummy -dummy: - $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy - @echo - @echo "Build finished. Dummy builder generates no files." diff --git a/docs/build/.empty b/docs/build/.empty deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 7713f1cadb0..00000000000 --- a/docs/make.bat +++ /dev/null @@ -1,281 +0,0 @@ -@ECHO OFF - -REM Command file for Sphinx documentation - -if "%SPHINXBUILD%" == "" ( - set SPHINXBUILD=sphinx-build -) -set BUILDDIR=build -set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source -set I18NSPHINXOPTS=%SPHINXOPTS% source -if NOT "%PAPER%" == "" ( - set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% - set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% -) - -if "%1" == "" goto help - -if "%1" == "help" ( - :help - echo.Please use `make ^` where ^ is one of - echo. html to make standalone HTML files - echo. dirhtml to make HTML files named index.html in directories - echo. singlehtml to make a single large HTML file - echo. pickle to make pickle files - echo. json to make JSON files - echo. htmlhelp to make HTML files and a HTML help project - echo. qthelp to make HTML files and a qthelp project - echo. devhelp to make HTML files and a Devhelp project - echo. epub to make an epub - echo. epub3 to make an epub3 - echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter - echo. text to make text files - echo. man to make manual pages - echo. texinfo to make Texinfo files - echo. gettext to make PO message catalogs - echo. changes to make an overview over all changed/added/deprecated items - echo. xml to make Docutils-native XML files - echo. pseudoxml to make pseudoxml-XML files for display purposes - echo. linkcheck to check all external links for integrity - echo. doctest to run all doctests embedded in the documentation if enabled - echo. coverage to run coverage check of the documentation if enabled - echo. dummy to check syntax errors of document sources - goto end -) - -if "%1" == "clean" ( - for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i - del /q /s %BUILDDIR%\* - goto end -) - - -REM Check if sphinx-build is available and fallback to Python version if any -%SPHINXBUILD% 1>NUL 2>NUL -if errorlevel 9009 goto sphinx_python -goto sphinx_ok - -:sphinx_python - -set SPHINXBUILD=python -m sphinx.__init__ -%SPHINXBUILD% 2> nul -if errorlevel 9009 ( - echo. - echo.The 'sphinx-build' command was not found. Make sure you have Sphinx - echo.installed, then set the SPHINXBUILD environment variable to point - echo.to the full path of the 'sphinx-build' executable. Alternatively you - echo.may add the Sphinx directory to PATH. - echo. - echo.If you don't have Sphinx installed, grab it from - echo.http://sphinx-doc.org/ - exit /b 1 -) - -:sphinx_ok - - -if "%1" == "html" ( - %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/html. - goto end -) - -if "%1" == "dirhtml" ( - %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. - goto end -) - -if "%1" == "singlehtml" ( - %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. - goto end -) - -if "%1" == "pickle" ( - %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the pickle files. - goto end -) - -if "%1" == "json" ( - %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can process the JSON files. - goto end -) - -if "%1" == "htmlhelp" ( - %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run HTML Help Workshop with the ^ -.hhp project file in %BUILDDIR%/htmlhelp. - goto end -) - -if "%1" == "qthelp" ( - %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; now you can run "qcollectiongenerator" with the ^ -.qhcp project file in %BUILDDIR%/qthelp, like this: - echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Home-Assistant.qhcp - echo.To view the help file: - echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Home-Assistant.ghc - goto end -) - -if "%1" == "devhelp" ( - %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. - goto end -) - -if "%1" == "epub" ( - %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub file is in %BUILDDIR%/epub. - goto end -) - -if "%1" == "epub3" ( - %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. - goto end -) - -if "%1" == "latex" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - if errorlevel 1 exit /b 1 - echo. - echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdf" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "latexpdfja" ( - %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex - cd %BUILDDIR%/latex - make all-pdf-ja - cd %~dp0 - echo. - echo.Build finished; the PDF files are in %BUILDDIR%/latex. - goto end -) - -if "%1" == "text" ( - %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The text files are in %BUILDDIR%/text. - goto end -) - -if "%1" == "man" ( - %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The manual pages are in %BUILDDIR%/man. - goto end -) - -if "%1" == "texinfo" ( - %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. - goto end -) - -if "%1" == "gettext" ( - %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The message catalogs are in %BUILDDIR%/locale. - goto end -) - -if "%1" == "changes" ( - %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes - if errorlevel 1 exit /b 1 - echo. - echo.The overview file is in %BUILDDIR%/changes. - goto end -) - -if "%1" == "linkcheck" ( - %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck - if errorlevel 1 exit /b 1 - echo. - echo.Link check complete; look for any errors in the above output ^ -or in %BUILDDIR%/linkcheck/output.txt. - goto end -) - -if "%1" == "doctest" ( - %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest - if errorlevel 1 exit /b 1 - echo. - echo.Testing of doctests in the sources finished, look at the ^ -results in %BUILDDIR%/doctest/output.txt. - goto end -) - -if "%1" == "coverage" ( - %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage - if errorlevel 1 exit /b 1 - echo. - echo.Testing of coverage in the sources finished, look at the ^ -results in %BUILDDIR%/coverage/python.txt. - goto end -) - -if "%1" == "xml" ( - %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The XML files are in %BUILDDIR%/xml. - goto end -) - -if "%1" == "pseudoxml" ( - %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. - goto end -) - -if "%1" == "dummy" ( - %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy - if errorlevel 1 exit /b 1 - echo. - echo.Build finished. Dummy builder generates no files. - goto end -) - -:end diff --git a/docs/screenshot-integrations.png b/docs/screenshot-integrations.png deleted file mode 100644 index f169e4486a65472f5c0dcef7285a03970c7c2c3a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178429 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%0DfB8S(D)`v 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 Nu_utMD=oca{{u)synO%w diff --git a/docs/screenshots.png b/docs/screenshots.png deleted file mode 100644 index 1305cddbb9dfe188673af3ff58fed87a42a035d0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 231797 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 G4E!JZTsQjw diff --git a/docs/source/_ext/edit_on_github.py b/docs/source/_ext/edit_on_github.py deleted file mode 100644 index ecaea5450c9..00000000000 --- a/docs/source/_ext/edit_on_github.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Sphinx extension for ReadTheDocs-style "Edit on GitHub" links on the sidebar. - -Loosely based on https://github.com/astropy/astropy/pull/347 -""" - -import os -import warnings - -__licence__ = "BSD (3 clause)" - - -def get_github_url(app, view, path): - """Build the GitHub URL.""" - return ( - f"https://github.com/{app.config.edit_on_github_project}/" - f"{view}/{app.config.edit_on_github_branch}/" - f"{app.config.edit_on_github_src_path}{path}" - ) - - -def html_page_context(app, pagename, templatename, context, doctree): - """Build the HTML page.""" - if templatename != "page.html": - return - - if not app.config.edit_on_github_project: - warnings.warn("edit_on_github_project not specified") - return - if not doctree: - warnings.warn("doctree is None") - return - path = os.path.relpath(doctree.get("source"), app.builder.srcdir) - show_url = get_github_url(app, "blob", path) - edit_url = get_github_url(app, "edit", path) - - context["show_on_github_url"] = show_url - context["edit_on_github_url"] = edit_url - - -def setup(app): - """Set up the app.""" - app.add_config_value("edit_on_github_project", "", True) - app.add_config_value("edit_on_github_branch", "master", True) - app.add_config_value("edit_on_github_src_path", "", True) # 'eg' "docs/" - app.connect("html-page-context", html_page_context) diff --git a/docs/source/_static/favicon.ico b/docs/source/_static/favicon.ico deleted file mode 100644 index 6d12158c18b17464323bea3d769003e6dd915af3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17957 zcmV)IK)k<+P)ht(u002dT zNkl z#0(0O1SH2L=gm3JOxN#^u35tOJ?EWs;BI%{zM;~ss#~{OuI~R{#=HXXZmn%u{cXaE z0*c=$g#p=@AfqLp=k z11Qi4C?MYURNb?q}LqYwX=PpcW{!v8USK)mG1yRK=M z+|xdq9ltSk*nOFiW!_aXIQctovF>F6@D^&o7@~nQWpG+$wIK%^; zP@)F+08nnQD6x@e62Svn(x1T~gQS5hh2U*Yfo4}+>~61ZT2?uuC~v^R`E$>kxkE6Fd{bIgEr4Jdq3N*_gOoc$Mu&=766qdL)7}O zWxHMHTF)^Vw$%&R1y3ec5gOHT;QlSZ0SI#$0f{VDh6 ziH+szxZigk&RdY~y&FF(>HeX`Y~@9L5=bMCL_j#l2DH)u;3F)ZJOe=309|L#vkgE2 zL$v?Ansj@<)i}Th?QZ(|^V9GBQo7ZXnEz`7K)mrzy2+LV15gcR0A$b%fP6zh>=y%w zr@sTx&LOnu-J@@BTY6K>_={N#VBjk7+cV~OL*9g6p`tNO%b_Q=>rlhMs-#Y0+ z70{MTo!$&UU0jNePnTVX{&;wx~|^0a-cy5qljt|;28(2aiG+%c0s!zIc{LF zRkFaJsC4u>R7>i4==fFCv-?iHh&TKn!*hQ7u3gvIb?g_DMVSB+XNV2?99G}2Q@{E~ zkLy8k5PS}Ifl3p-wRk2<)A0bMw0_Z1KCS{Ta@pGI%bb8))8JqA=i?Wp! zXVjKQ|EDdI7RtG_Ap|Dak18Xv>BPVpLrd&eY#qDSRU^A!QeWSH9KrepMbpGb`?{?V#-~a4JzJf zKc+2b*nP+T#rWZ{-H*wOSpRBe2|h17HlENiqfm}&>8S=PVG9RKsV$iz20W7@$^&?+DfBxr4^5l@H!2|zQ}Xbw=U7cv9% zaX!$2;E7(WPeJBKaZgKYNcl1Nf%J#{FI4;&odI4`{$mozNAibE0BX6Iy})?VI2mM& z+#)XkWxOOGf)>!2Q$SMj$g#28TnPZh#;t1q8iJ{#i$D)s-^RS!9*0>VqZ1czD|gEC>eO4IZzN|khE_y; z; zFF6|8#Ddme(J_)hQObb?R`D#5qi5(e(8sw@iy(T6*T-uQ%}0gaaK49|dv|V?^e;3k zNgV3_1hXI5abCqCSoqYT>uZOBydjJqBHWOpgW%k2ZV#@(v4We|S zaDq};TmSTEJIB8lvxfX?FPRR|7$F4$T*6My0_tc_0Q3?LSO>mc>ekDGgyRqxLv>xf zc3Nd+Y=SWq#_ZYHu-aHzY-OgEJFILAK!(}pB{oGp#yRQ&E;CpStk$!b0y$Qw5}%zMO_jBBV?VKJT=J8!qe8 zH1!V9Yjw0&4h#N0)Uswj=-av#1!ul1Oab}PZA2f?h0@h|8{}bKDpv#P@;hGw%E@#B z;DAA()aDbDoBIu3F@4@gl~+&bF|{_upk8FyXkhS3wYOq+Q{8a{qvltLT@Pa`0`#YX zb`a2K_>E-HU3>$;6dQnJdbk>59MkLfn_v5Ec?u@|)A_BW zROptM{#NjFU>JS42UyP*_5)E;@PLpJRu)+#0tMuf4k-tN-#Vwm_zqnn=}!Zhj3{Xi zf8KCtPR*S_1&@&h&QE;75TMA3qX?*wBhHV&B&uXO&`HMf8lc1*#C(>@t7jY^4vFST z4H+YCF-B@#5ARxoQ^4x^yB}5NL2Xd)0gbbIDOMh^`@W|#N$UuJ_SPW`hJcRJ#VBa4 zehVO@@DUU>1jZW$#_ZCVD2vupXo68};5dY9z2hiGnZ6{@n8(j1mSz@gO}z^>&L+0Z zr*!=u7YMM7HNXZf;WE&>L0yPW^uG5>pxNMr-{pI_HL>$MNe*=Dl93!N2C`_cb3u=2 zjaf8~t&34}^c?*O;MlzD2;)IYPTYsiS{R?vWp4Uv&;%V&G7A*6 zB*|1@y>pNp;2`^)w?S?tMeYK!d4U&z3ev~`LR6aB=4%5f2H}`!uQNE;n5V-fi~@D_ z9#S7}>BjL#my~2fe6n4aTm2LOeV0!G=+75^cMsUAyUSmLb+n6)=TwC}Iq8@*=5x1hT@Z zkQadEv}Q6eOD>U{0C6~_yjhF3Ovxs>_8!)*CTWUJpj@e80sTH((4iD+wg4+R0`4%-!$|$A<#oW zsnyQFH`B_k<2w6S1JVV5<&XI9QEYY7fkucP0*>HH8j!{sT?_2cOL-jh0tRRXM4yiS z>7_uk#i0a#hDk4Uxio14bRV9P8u$V9M{T5k0W*1%vp{lW0;fTFi|{yY1Dghv%!^(H zJFCl+q6a~%nE}w|>*ROcBhdZ)j33-NkTx=Svor;M;#F3H=4uKar0k7*UOt5JOS=A= zwgnX4VF)a!JlwZtABgZISAg>kySM|i(An+u0=a{7=TqQx+aFCd!W%@y%xI+oUzv_K z&d&zAGDyw_=_e<17Ld(YE&~b}W}1B>0esyok)w{Jc#t@Qaf1DefuFLE?+1`mEy%oOJ zT4(LI1XJiB35OLx6>F&iHs~dK7U-k&)722okN)h#(ogq8^uFlZUJW#RGxRU7z@$&Q+>_V>x-U!rBXB0@ zrJTw<(0qRha{iJpr3dUCSV{C(cs8)~bhj^A1Rk(}mX|VR`U2a-b)fU4nZcwJz=p=P}UX%w{V{BPyr^EzuM(B4KsNPIARhc=Bkgmh+7&? zZ};(_cYrhpxjcQ*?27Zs^J=$yb^1wiwp>M9;yD^H5z{SWD^F}Uz3P|_6pm9(VmoZd zIy{ny=>t9qRa-zq9V__>_)q(5chJYQn^raF*FgXV9B{^WF+Jh}7G#3j(9FyrgM z>EK}XhH*+b^o@wnu+k$fIWL_UI(d@xwM4FSA~{4=`ix?=ArmY zA>-4y#&R7jSaoD)_;XmZr|{?SLWo?iAMrh0cvp+Y@oV6&ue<&oY6{LtJWK|VLjoRf zh$yMxJT4z`CX`jwjrKzD&S{&oN^gMQmK@kvtPE%mlT^L?Su83dEJbe*zc6Lu1#zsEuLkbEWx_8L)0z(bUKhD15E< zRj(T~d?m&|*Ux}q(biE1^a8m=Ujc4l6WKtD6DR9| zM42KF0K;j*2q24|^f&1og?cYQY}awDF9~!UFb)E|kAnxg(%0H$OuYj#rbWi$0giIM zPawYe(WZVM+cg4E>o9iTvHCvZoASPI$Lmne0-dOtS_09Ryf3_+(EQ)fMtZ`Og3h7D zfzTs2{pLVN@RI4wJkVUN6W;@fdLS|L=M#pL4psj}yOIUvN5WloKFEu7ARZDXxV5qr z+Vo9MaoYfI>Y1#A=#}c}5@`HyLbB5lI(AOI(5-?k<4cJE6|*Cc>ux9?5q?r@q0xQu zeQ5|9*2i%`f<)DpI1%tFiRkZ;`dl#TjDVXi>3Vni{jhFlL1y(T(C%EsAHa9i5(X_7 z(OQn~`Yj`k5NNV4*U{i*M_vhE0NyyKaiA$U`{goe1Dv7nJIjG1`nG-q@`?UQy8oDdwmS!+ zXX+r3cINR!a7~=g@g{ypZowx4+SojUqN%IWH?k!ie9a) zVExeIiy|$cOOMpMoJru`;;dy86b!E2=v@Q5Q_8ENM?fB9C@zG4bZ5%nko1NNAA!0I z)hy7Dxt}Kh;XjgqYD7N*Ud81F@S@sKM?w12ai=+r;q>&TGm`!RK4S-iL2uS4^?YDD z)jS1clE{AGKl(Ep^n9g^F1kgs_G(8(Q+i9zr!0C+Q6+rb({sY>| zE&%#iBvMknzos*Qj^Gafruk8a0ey}S0koU`48VED*;)0Dg_sr)+s=uFHd#cF*mjNX zGE!?_zbN>gyE&`@4)7|M0oQ3$O@!!t?>(;&n!gfSL@7+k==6Q)LFn;QT5@0qcr{*2 z{TuWJBAkZ5N#)uS;`<}i;sSi9X?sb+g|z>ICENk_cPhEjuOp6L2AIxB$Apoxb0_0UEp!|GcDu zH$;ndBQ1vh~2F%1Lp zwk@W(BmswsQU|(LcM|~)vZvnlRLjwjw*nK{B?<6mv~a*iV~$KQw5=h=h@Jjma^1Q>3pz73TI=3@1LLL3PK z{i&lVun%-MP=QMrUl(wI&bZ=lAG;(12Ux9JfJ1r{4}gBlGnxV3Io@oq4K$a~VK%~) zQJu!b?}hFS(=K!;f;UKe=o+Ay2t$D;BoGgzQ%eHiQA8H~~nd&^DJ+jR4|l1msdfC4S^m zH6WClSuD2r##_XdzZX)i7wB7{g*sgKfiBb@?diaI;MJ;od=AA4?bs_m!{7tq#M1 z^Q*>3_XF3_o_J_dNPlHBFj{-?4CovkMF`|f zdXfRUl-(qQjG>xoK$MVtXN$nM_`aC^ps(QXw~j|R9RhkEA`^jYL3e_V<*2P6eZcgCtL#E-%4xZmO*rY zexehA8shi>c#%Y^fNY{9145YvQM54aqwF|BIQAF2Q+V`mebxWlTjK50+aO8YC;!#E)-Ve27|bOA{t`skXJQ`lC6T^Fo-~30 zv&bV6^kbBGkZTO_F~?6OzC9VJF9YV}S;eQoIvuCyfKK5KT?f%c(PzCvX#Ptmm9JsS zubqy>KLOqMq>XjAgLj)ZQcnjhMopB8*iOGj3?P0Olh4!9`ta>qZClW1=@#?~4%q}383X2a6>oy=rm_6Vuk}stTIAi5ynu0de6%+$6 zOD{jpPB&8x-ro$=y`a-{mF;-fnTQRT-I8pP%zFs~Tl8=K0-~Hl?|R`)Jqn^!PzW4k zEgOMcy6b*mpDATZ0ulUxj3^M*^|s?)q=!M8${0$(@%T+Tf=rXXWCI&1qYAv6^m^S1 zIz}f_1iBxG2v9>6wLrj@fCl=6{tC&1g5SI6!p$psY-=zFc-&c4avA*o#o^p=5GanW za;w1E;tcb80u8)P&X+)mGfNu*SL@AkE07_t@e>eFKhl9~@Et*Osih4B_?kT0g3cz0 z_))>f{i1kg$A7>YV7)%9!$7a&Qr!#DzoKt>xzM~cG?=?#%EXQj$6W#4?@YVhy#>5( zdb6eiB~&pK-#?H9I2m1KJPQosY$AY1I<-I&MbrYrNTdZgD_Bk!DEzMWIThG`Qu%;r9OS)H z^Q1Q)k~;>v$#>A9I%R{i9h!Gc9OX;~8OR9AK}&g+1Na9$Vy<<-E6}idkon@0_R(4CpQ7fdOrJsCi0py2jp43QEmlXd6(Bg z->}1Rw^7Ro2%EXtaV)*LmyUZAP01$mR-7zv3t1$N3}ICXmV?}0urepK5H!F!?cuL%!3mxI??Z`48{ z#TJQTxk6Ha0W72$a6J_!(j7BgeF+z)hzPKOZ*>!}j@{A`bg!<~V{{7{F4+LOMt>y& z3;<J30@(7_~Po{0rEOkr91&y65m^nz`1`k`84nVTt2DA-r#&l z`X#VdUI%ZVeyHU@yzi37^9e#LsKbMl_k!OzLt#SaF1aZ;0^iHc6$jw=laIU;c>u_l zuJQ=T--P9TkawMR%m;GiUFR`itWJ{Kfy?A+o(27p0F5EQ$Np-s@A+$xT;NLrf*ha< zsMW`K2=q~=>nRZ3>#guwLyN~k)A;V) zzNc&8_m>X88a@Te_k`!@6uAG?E)Cl}R_UhK4OY3(d1mvFpZ z1N_0CWI|wrGfIYoI{Fo5a3orN*c%BSui5=ocsNwXM;d84+_!@)-d7J&b7%81E;dC;xPPv|B(ZcI&g;Zw|oIS&T{gA z)0~L>1x%1%c?h^j?~~7fSv*S`1oR0u18^xn1L!b@;s8nf%vNA6hxBdmzS4Je7PKf% z*hO2Iy1rwZ;04gVbLv`m2t+^e8tPX3pMw zMw{IwP(C7D!znQ5oBa*Kg|N3-n19BhLy^y5Kzf5;-I365ZpK6IJrKP^Gjt!m znJK199yK`8a*d#nJUZ}+q`^(Mb}C9*1Dq>gRriBG3v#O?zkvQEePkcdM4pyKzyaCo zwgrZ99j!r=^i3u}fERt2q5h)F0OUG5ZXBf>IiQ#89V`X8K*#GvaQQPW&JJ7(y&p^; z?kKb5C`B?#zLe-ntY%m>hG>1kr0{d zJ*X#xJI%dX27;WeXAlDJ&~7>jC?QHEe!xTqM;$wPRI>nhSZDAJ==r*la?n#bgCgKN z&Senf{#4V(dji7$=rA1%+Jp%lfaZ54el6W0{jp#pwm|qi?`^-+;&vZyT?H(^lru8Y;%twT|8Z8(QT}ulf$c1NAyKgM203 zWgPITjFjQvEO7=nJK*rkHC1{Gls2ioAlez&st>tMKu^^otpg6~2)!M+K>n1KKreoy z7y`^g=mz>J$hjcbQ$r$9&PE*tl;M&F@-T%GfWP|ZO^d9Azppv`L9`?2nCSBG7|>af z=fktW>l0}m9s-fn@WXXC!gY6b{vbUEu6w-e@0sU=rs=25gp97iKX@JjYn-3?3!+P- z;|Rd!j-{ibJ0W*p&0m2Q(D<>0nUVzI4|I?I4QnneS`qyfYMMk>=_sHb`K*HMcJUWV zF$7<577+p_v0e{@uA;Fd0^``s5}=Nz^uj+rooNIhK$t?xLASG%t-yH72m)nVprycE z=_TzUsd?acW`c8p_?Zk|V45z2oUHQOypC|7UDegzbOr||>GyR7DYNb!QBc@CVj?klRelrx9iDw_MazSyLD?6P)94A# z^MNk|9Y9WYuM7minGfhJV)CGa&(hysgApd09T zJk@Vw>3NCzGP{T!hS;E^O}02dL^FF@DG6-)x@A^V)c;QcKxdv}0tmLj(kXu7=Y zy$|}1bH6tc^h2jb;BnBG$Wbey8=#nIAgJM6&yNPeT_a1ix%bgih7_gay6%dlrn=x(CE|n;ZgzaXL2o{ zf;UR1QVKGKb9DyjliEgygSKNCOYje7cHpQ>wvY&Uz;pVfz5)7*`X~6VVG?hFKBMFG zQD`zZVXKrv;_2>so`uTiBNvbY`wvx#cP~7*Xxp%G9~dw*^KIvIDC$^uzox@qKjobr zt%OJ;U8Xld+%)HOITw2VnfA8xKX9Jl4_*enQ@iWcKsSEncJMYxZ>Jl09i5-$TaXK# zfO{42pA#Qg58ioBb>Mf9dz|wEb3o2@7CXHG6_Ja8y*wy8K|dfMF9VHOBSpXn5*Y;n zeUMVRgN{aM404s7@k!+r`hk8=Q+X2@!%CR~S||PFQ;-Xt#?D#5CHe`U17(2`JOLC+ znM6Rc75)Hu+WFHR2)sxw1Hs$wd$KZ`2EUSaaM^p!8w5JSEAw*3g!@3%*O5hf3#@jF zW<)2!>iIk`#4-fjmnXq?J5HCU~Fd2r8lPoCe=GSHggi znK<>Uf$kg)=^AIMk_x@Ymkc!T#`N%Kqmgi{SA1>d~45(KWhH*MpoZ50VMN zm!0l%A%t6b#rg-xAkHHM1sBwQpa)@AUe42zeh@0?e2uu!dqR4llK^a?iX*_aG_&}` zn2i!M2MeeI3YbqZI4W~-Apf7*-P!ff>Q`K^nV{2gZQpx{teqLEdn#at8o!%L7ga;9D6Y zPXKMHlS_a~vSl>zEe+WL0Ud_Fb9S~54ZYn?%;Yhc5kRVpm79Ry^d0U8eP2AL0pIhl zI~+)J_Q(^!X);l!1B2-#>w&YpX8ImTSLr1m0qq&0w}Gx8hhe~WKf90fG3{{R+%|(#O4L;E2{#Y91s#QyU1m|e4tOY54y>5hczcG1NwhP;z% zrf3Ev-xFvdlcC4n^poTd==(@|#3_L!C(vJR2XBhr#1 z+@TV-uS^n@}q0 z(Co3$JMISHH;M=Wr_+ac;A;j@2JMz7pDWFPXZeELV9EIf)1$9|dyXjCuzOBrU+)ok zWnqpR-Uv5$YWGXvV{qp4lKJ30OXi>`$bH-^iBNE`ZoGa4U+muZVssHK*;nvvbQ(B6 z^91qW-OMz~AZ=MNOBTQ_CG9o`Qeap~!?)ZnK#(Xwh`z6VbRRf>%QI38l0q$KfH#tp zbsu;s`iK4o?6lCWn6I%nMRGk*#yvzq3*?}4K1d1=J2wMW&JyP;V82uB_5?aR#jXIK zIL(~-!1eNgGZW+@dhigiO1jE5K!8d73+!Puw*o8qhg~4&4gc$#sww4pwZpv<{a@9O);-+>-0y|;o#|@V|BS6vb$OS&3 zgecIG&SU~>*h>-6z;+W`+acRhYDoq1aoG+y>>~@D)5VpCVAq*@k<_t2$B^1pe3FSDK+ zNchuTEccd94d_V!oT`rX-69eT3%8$+G*8s{`amZC6m68Iv;(5_ zuvS2{h6W@+W?XQN@-+)f0%8@P*dNb4IsRlbCY`R(=u-h`p6vWGby=ykeX zXMujAL#PCSKLkHWv>xD5Mg_2#ef$SxX%X## zQ#l~LfsOR!Eua9;2*3}Zsj^VLYCIx9-7(qBfMdL#v2=Jj00BNa9`X8yUB}Wm?6)%A z%8gdW4uAH)8u5 zri;bQ`MEDW+7NF`L}MV#HueCuWRi-1@SzU8LT#>JL1w?WnKXe(z1#J2p8~muMw$qV zUo8086L2PREv3NeWD_`s5Yl}Rd2P6fa!7kNc%jUOiAC*Rb|=7)zq1ZWFYub`N16xx z#0)kAoB4whU^}~P{Fx*Ixg?MXB(ResK#^q@EhC*mV6bHv+)h4;;GYrz{eT*N1l`GZ z08HUV9AGP3NB~@2K@I5dJVyX1q#F*%S3pD1kMR%b#Im!+&T+K0^6C1_FCl<{KJMSp zL4GOYPdKI=t`Bnde^vG2;<0)z9_V&h62up+LM><+wK$+G7R$Pp6a}EA} zC~u9$BLA(6eKhX2oWJEP0HDx>eS#e<2wN`SFn`N3rR9LRib(XDq9~*Fh z`Q+<6pv9V}nZQnVa|9@`P|q-R77|r#B$rMjAV{1YId@0{@@=h4rh-bK0RbXFv4ybK zT27K$BBTQ$KV7n?dIh0QM0S=Q-KIj5|<7;3t=a37WOn}Znl@D*-&l&(4n#!4DGCGe~1f##nmt?77>Wp#A z4BrtZ`%AzRb_Vb^UqtEkhUloJbUBHg0IalR%~_^Sc$@-2BRdv4$p(-@6$0e?5ji@8 zMQj5`@c}OaoB59oK!A=2P(>vXAc1O%@ni3r01XHkqLXP3EWxD-(1^|C1Dyy_2kfzg z@}+oG0p--v8pt$S57B_bpzU=k9%vKo&nv)KsyPYBGYMDfBgzwPr&BL9rkIEx{c zfYO(NLWpKSh{hZMnzP4p8m3Z=zeFmKMizbWb13cx(rH2w(1;uofUV@?pEGET1B8gD z6!c8?>Mg)WTFg(tKAr<0-V(^~v!Jn4Y}}baekfYri)E*2Yr#j9%&6gZxcWpO{6$}_AC-vg@UfMkjeM`phy<6 zm^Gj)b-!)|8kz_0Sf_04cf6Jni=vhK`1;rpEMEd0)#hUxJD$Dm`x(Lr$Z&aKi>_9(qhK^-;feI>7JOk7(B8 z-tWN?xg2Of9gBeq;yKyIr=WZMoX7Hzy|){sIZ$Ui962UOn_2u-nK9@pmH_Bqc20Pt zA4=;;FFWv^W;uFx*@5qAuZX1 zAKqO71T3MMYvJXtnRNjRn|ABVfMv#X3E;;R29LQ8TImuvu06+;FSq`AoZa7I2RaVb z6V~x)^+l(vYpwsFwG$h!WA`0X?gI7Ku8rS0t~@$^|G6LQ7Z)>Q?LFuJ%r~av)M*V6 z;7ZN`vbdhUAVD(e0i=;3je#`E7zFGkUz!3nY__Gi*f`&3@GSrP1agY{)0r>TkEKS*VX^aarBSIgb1FL8X9F#jb3Al%I*Z?|9{j^1^ zD6I!rjEg;d`?VHGvn4KLUc4#HWfk)9A^HcZ|KrGqPKB7R0*~>~Z zTf1Xv?|r4Rl!a6SMb!8~rG)GAo<)t26FHg)Bf;!#KQWd+f>_*t9KNgBN2H%fzSfqA z#A76Y9G#c$p^!@8CFv=Hfpg?vc?|fA`LY_=EZ@odKp$>l3b2tR1_K=l@Ey>d26(`! zX2`#9%6g*dZU?0g04;d}Lqj?MXPL6sz_i8;`UBA1l!pDLRCKiC;(Kgqh7>0JS5$vp5ALgsc0(-5_661$bOS8IVpoNub-QAOO;ZF6;yS&y>Sta%C{! zQcEL{3|43kkV*q-1q8{W4#+22596Dc@j!qIYJp>>|eq=_HUA^d=E> zIU6Vhn&L_)U@hATgY>F5s9TW^G@vPY;PjVI=m@pXML*J)A=*|3IdehAv&lIOjFy+A z36RUxG7pHbgS&ww67hgi`(VWiW1BuE;j4U|O^aC$!1;cnN;&)2zizLZT%K2VgZGm@ zk(|w4bSKEsv~7i^?R%DLAk&n_0v6x^Lv$Vof$?0byFoAY9`=3%@7(CJ$Y98x6*v@K z0Rz^heWzIvn8Cp4kDz$D(JavOysNwd;AdUpO$V0gL`?yT`I+TF3WrGnN-Ps$9CcI! z@s?3A-~0d})Dlkzh&O=L0uh3Ez+;?B2-aqowk8jDUr{xM{lGj110>%P_=Dy!_=C*9 zX#t^J_igHc2iQg`5a%PFTq2a=J6rtd;-Go(YD`C|AsKM&fW2p@hLeaA52$_ACPF)0 zpc8k<1HcH$lO&K#=N9<~b{3Sc^^#%3on<{~2+};TK5jP1UBT;vaUf0HYl7#3eCf_~ zyMlOfwi5&fif^wirq~i;mYW3d^90_^7epY`(s@wsDV-X?v)s~E_KA`f(glDcX7`ty z($K@c1r*B%n89U!Di|3>0&uBbBo_d!-i^`Cp!0N@-U8(EuRaf?+gEl9O~l8Rjo246Vy^5ATRUS( zs`BmVSJaf*m8_)}9FMPP2u?HULwjJj51_J#YtsnBk6y*?@LIp=Z?xa)^2MpnWoDFiDbHB42Jig$N;OiHozjxGK+kgTa{dLb^)jM=g0AuI^|FC)cv>$64(g}u0BX#Km1dHt zs{WHRtpO;qB`Vv-Ra5_QDgPHsrd)1TPOcGf6Yd0J8F*o>4;c3s;%Ek(Y`K!x+L9P+ zugaE8F(oUO^0jGw=F?cVq)Y?6PpGSGoJB@RY4&}!o$`r154<6#$^?)UdC9p6c*||& z&H>r)eC5ssb~?%Kvp|LOgL4J&fb^0oAYP`*V?a+9G6JY!E-p}ErxY6*20W9Z}nrTSPvLMq#I)6%$!3R zvjXHDdDm?XTqQrtM_mYkwBvGM7Nv4FkR==G0xXf)&N7e+r;hbN6PfR< z2Ts;!yeC2KblPh*ki$dz80d0c&r?8zWo!cCX@UciVdG3Ot7N}%V6sVsMX|4H`5s0s zwrwo`U>efO5M1Jyy4MK9&(n#VZW%OI8G;%ap+&6yT}D7Hj9~mtR-|#gGTa!dZs1ONk+ndCElxX-LT9wR1NO^H$Y<2l?zA#wmi!QaLK!%!DyVK`*iKVNnFmu(_ zMF5)buXWPE2)IfH`t9f*06N(HToaAOCgDyo0@yG8jnMr0X8Yd_lr9ni(0#Uq?$DnA zWUzm&`6f}rT!7T)Yx|xslP}D#9!pRuPyQ)lp#<2)Ft<| zzh0FCp^hm(4w;NYseS|`a+qX5jbmHzo6(cMcJ{K(h&%(>dJ~n6E$zcvKiHFYY_fwF z*@S^?ej^XaGBa~2X$a6-clZe*q$5!vVtW5x^I^N@m^#HAR?F2#NXV45_A&&3tu~H$ z6WK|Ips1y(&lO*n392ty(!|f6Rb@IwoY~2fEN%X56Pf>+HI!~jb83Ae&1`Gm_pl8X zKX=&QCaG#nCrOEIuBk5(DlMB@x>;Y+ZD0^L#ld-=V8g{AMAA9Y*b{hHB9^P9G8edVmT zoGtDxf!fq$USq1>4fNxD9H88?P?gvckZXufvM*|;7$+Sxw8=A+krhYHY_ubW) zNc3#2^>c$kXYmnnz&O%1z;|I30E?>7>aay$zUMIW1+1ia|QBPpC)%OV^%j#v@ z5|&`?udV++L+m>WLEGNlivr1}bDZffN%czXJ`?$|FT3t!9}2Xg0}Aw|hs*%))aXAE zDXXk#erU&@nUn96=8xaF?8z$=u55bwS^1BvUoo|8xi zn)4eQfu&{#?=ex+lnVi9X={yV=1!sww6Sse0^62sY7<{=N@O2~+rYmAj(&=@hpn;O z4Y5s4%U@}Tt7QTJhYcZ7)3%%18eGdZ08X(rd@F+hSZ1*?v2E;tjjy#4;S6+=LZ*{PPUL3f(AUTu_J z<4;_AnW&H0T3l_3UR+<&XtjyHDqnk%Y$LeD`oxyLKFPK_0sGWT&_|$}St=%3bdwQQGb6|xpOC~SgQE^6*GHsoFzcd`4=1&~ zPDwY$h<)N#ZC5sAOQFYM%c<0m_Mo@xNbigA8_^eQR#na4{9N6uWfO}3eEqJvb^9LK za@FkT1A9aL6ECmY_5T2A%A}qQXC0;h001I-R9JLVZ)S9NVRB^v0C?JSOvz75Rq)JB gOiv9;O-!i-056;c)UFJvO#lD@07*qoM6N<$f?sy{TmS$7 diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png deleted file mode 100644 index 03b5dd7780c0ead39132d421f97f7de5cfc5919c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 13441 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�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 O0000 -

  • Homepage
  • -
  • Community Forums
  • -
  • GitHub
  • -
  • Discord
  • - diff --git a/docs/source/_templates/sourcelink.html b/docs/source/_templates/sourcelink.html deleted file mode 100644 index 8cf2c4f92ae..00000000000 --- a/docs/source/_templates/sourcelink.html +++ /dev/null @@ -1,13 +0,0 @@ -{%- if show_source and has_source and sourcename %} -

    {{ _('This Page') }}

    - -{%- endif %} diff --git a/docs/source/api/auth.rst b/docs/source/api/auth.rst deleted file mode 100644 index 16a1dc69b6b..00000000000 --- a/docs/source/api/auth.rst +++ /dev/null @@ -1,29 +0,0 @@ -:mod:`homeassistant.auth` -========================= - -.. automodule:: homeassistant.auth - :members: - -homeassistant.auth.auth\_store ------------------------------- - -.. automodule:: homeassistant.auth.auth_store - :members: - :undoc-members: - :show-inheritance: - -homeassistant.auth.const ------------------------- - -.. automodule:: homeassistant.auth.const - :members: - :undoc-members: - :show-inheritance: - -homeassistant.auth.models -------------------------- - -.. automodule:: homeassistant.auth.models - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/bootstrap.rst b/docs/source/api/bootstrap.rst deleted file mode 100644 index fdc0b1c731d..00000000000 --- a/docs/source/api/bootstrap.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _bootstrap_module: - -:mod:`homeassistant.bootstrap` ------------------------------- - -.. automodule:: homeassistant.bootstrap - :members: diff --git a/docs/source/api/components.rst b/docs/source/api/components.rst deleted file mode 100644 index a27f93765b4..00000000000 --- a/docs/source/api/components.rst +++ /dev/null @@ -1,170 +0,0 @@ -:mod:`homeassistant.components` -=============================== - -air\_quality --------------------------------------------- - -.. automodule:: homeassistant.components.air_quality - :members: - :undoc-members: - :show-inheritance: - -alarm\_control\_panel --------------------------------------------- - -.. automodule:: homeassistant.components.alarm_control_panel - :members: - :undoc-members: - :show-inheritance: - -binary\_sensor --------------------------------------------- - -.. automodule:: homeassistant.components.binary_sensor - :members: - :undoc-members: - :show-inheritance: - -camera ---------------------------- - -.. automodule:: homeassistant.components.camera - :members: - :undoc-members: - :show-inheritance: - -calendar ---------------------------- - -.. automodule:: homeassistant.components.calendar - :members: - :undoc-members: - :show-inheritance: - -climate ---------------------------- - -.. automodule:: homeassistant.components.climate - :members: - :undoc-members: - :show-inheritance: - -conversation ---------------------------- - -.. automodule:: homeassistant.components.conversation - :members: - :undoc-members: - :show-inheritance: - -cover ---------------------------- - -.. automodule:: homeassistant.components.cover - :members: - :undoc-members: - :show-inheritance: - -device\_tracker ---------------------------- - -.. automodule:: homeassistant.components.device_tracker - :members: - :undoc-members: - :show-inheritance: - -fan ---------------------------- - -.. automodule:: homeassistant.components.fan - :members: - :undoc-members: - :show-inheritance: - -light ---------------------------- - -.. automodule:: homeassistant.components.light - :members: - :undoc-members: - :show-inheritance: - -lock ---------------------------- - -.. automodule:: homeassistant.components.lock - :members: - :undoc-members: - :show-inheritance: - -media\_player ---------------------------- - -.. automodule:: homeassistant.components.media_player - :members: - :undoc-members: - :show-inheritance: - -notify ---------------------------- - -.. automodule:: homeassistant.components.notify - :members: - :undoc-members: - :show-inheritance: - -remote ---------------------------- - -.. automodule:: homeassistant.components.remote - :members: - :undoc-members: - :show-inheritance: - -switch ---------------------------- - -.. automodule:: homeassistant.components.switch - :members: - :undoc-members: - :show-inheritance: - -sensor -------------------------------------- - -.. automodule:: homeassistant.components.sensor - :members: - :undoc-members: - :show-inheritance: - -vacuum -------------------------------------- - -.. automodule:: homeassistant.components.vacuum - :members: - :undoc-members: - :show-inheritance: - -water\_heater -------------------------------------- - -.. automodule:: homeassistant.components.water_heater - :members: - :undoc-members: - :show-inheritance: - -weather ---------------------------- - -.. automodule:: homeassistant.components.weather - :members: - :undoc-members: - :show-inheritance: - -webhook ---------------------------- - -.. automodule:: homeassistant.components.webhook - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/config_entries.rst b/docs/source/api/config_entries.rst deleted file mode 100644 index 4a207b82e16..00000000000 --- a/docs/source/api/config_entries.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _config_entries_module: - -:mod:`homeassistant.config_entries` ------------------------------------ - -.. automodule:: homeassistant.config_entries - :members: diff --git a/docs/source/api/core.rst b/docs/source/api/core.rst deleted file mode 100644 index 7928655b8a1..00000000000 --- a/docs/source/api/core.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _core_module: - -:mod:`homeassistant.core` -------------------------- - -.. automodule:: homeassistant.core - :members: \ No newline at end of file diff --git a/docs/source/api/data_entry_flow.rst b/docs/source/api/data_entry_flow.rst deleted file mode 100644 index 0159dd51c5a..00000000000 --- a/docs/source/api/data_entry_flow.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _data_entry_flow_module: - -:mod:`homeassistant.data_entry_flow` ------------------------------------- - -.. automodule:: homeassistant.data_entry_flow - :members: diff --git a/docs/source/api/exceptions.rst b/docs/source/api/exceptions.rst deleted file mode 100644 index e2977c51dae..00000000000 --- a/docs/source/api/exceptions.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _exceptions_module: - -:mod:`homeassistant.exceptions` -------------------------------- - -.. automodule:: homeassistant.exceptions - :members: diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst deleted file mode 100644 index 753771ebc83..00000000000 --- a/docs/source/api/helpers.rst +++ /dev/null @@ -1,327 +0,0 @@ -:mod:`homeassistant.helpers` -============================ - -.. automodule:: homeassistant.helpers - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.aiohttp\_client -------------------------------------- - -.. automodule:: homeassistant.helpers.aiohttp_client - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.area\_registry ------------------------------------- - -.. automodule:: homeassistant.helpers.area_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.check\_config ------------------------------------ - -.. automodule:: homeassistant.helpers.check_config - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.collection --------------------------------- - -.. automodule:: homeassistant.helpers.collection - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.condition -------------------------------- - -.. automodule:: homeassistant.helpers.condition - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_entry\_flow ------------------------------------------ - -.. automodule:: homeassistant.helpers.config_entry_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_entry\_oauth2\_flow -------------------------------------------------- - -.. automodule:: homeassistant.helpers.config_entry_oauth2_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.config\_validation ----------------------------------------- - -.. automodule:: homeassistant.helpers.config_validation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.data\_entry\_flow ---------------------------------------- - -.. automodule:: homeassistant.helpers.data_entry_flow - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.debounce ------------------------------- - -.. automodule:: homeassistant.helpers.debounce - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.deprecation ---------------------------------- - -.. automodule:: homeassistant.helpers.deprecation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.device\_registry --------------------------------------- - -.. automodule:: homeassistant.helpers.device_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.discovery -------------------------------- - -.. automodule:: homeassistant.helpers.discovery - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.dispatcher --------------------------------- - -.. automodule:: homeassistant.helpers.dispatcher - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity ----------------------------- - -.. automodule:: homeassistant.helpers.entity - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_component ---------------------------------------- - -.. automodule:: homeassistant.helpers.entity_component - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_platform --------------------------------------- - -.. automodule:: homeassistant.helpers.entity_platform - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_registry --------------------------------------- - -.. automodule:: homeassistant.helpers.entity_registry - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entity\_values ------------------------------------- - -.. automodule:: homeassistant.helpers.entity_values - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.entityfilter ----------------------------------- - -.. automodule:: homeassistant.helpers.entityfilter - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.event ---------------------------- - -.. automodule:: homeassistant.helpers.event - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.icon --------------------------- - -.. automodule:: homeassistant.helpers.icon - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.integration\_platform -------------------------------------------- - -.. automodule:: homeassistant.helpers.integration_platform - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.intent ----------------------------- - -.. automodule:: homeassistant.helpers.intent - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.json --------------------------- - -.. automodule:: homeassistant.helpers.json - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.location ------------------------------- - -.. automodule:: homeassistant.helpers.location - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.network ------------------------------ - -.. automodule:: homeassistant.helpers.network - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.restore\_state ------------------------------------- - -.. automodule:: homeassistant.helpers.restore_state - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.script ----------------------------- - -.. automodule:: homeassistant.helpers.script - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.service ------------------------------ - -.. automodule:: homeassistant.helpers.service - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.signal ------------------------------ - -.. automodule:: homeassistant.helpers.signal - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.state ---------------------------- - -.. automodule:: homeassistant.helpers.state - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.storage ------------------------------ - -.. automodule:: homeassistant.helpers.storage - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.sun -------------------------- - -.. automodule:: homeassistant.helpers.sun - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.system\_info ----------------------------------- - -.. automodule:: homeassistant.helpers.system_info - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.temperature ---------------------------------- - -.. automodule:: homeassistant.helpers.temperature - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.template ------------------------------- - -.. automodule:: homeassistant.helpers.template - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.translation ---------------------------------- - -.. automodule:: homeassistant.helpers.translation - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.typing ----------------------------- - -.. automodule:: homeassistant.helpers.typing - :members: - :undoc-members: - :show-inheritance: - -homeassistant.helpers.update\_coordinator ------------------------------------------ - -.. automodule:: homeassistant.helpers.update_coordinator - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/api/loader.rst b/docs/source/api/loader.rst deleted file mode 100644 index 91594a8a774..00000000000 --- a/docs/source/api/loader.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _loader_module: - -:mod:`homeassistant.loader` ---------------------------- - -.. automodule:: homeassistant.loader - :members: diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst deleted file mode 100644 index 1ed4049c218..00000000000 --- a/docs/source/api/util.rst +++ /dev/null @@ -1,119 +0,0 @@ -:mod:`homeassistant.util` -========================= - -.. automodule:: homeassistant.util - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.yaml ------------------------ - -.. automodule:: homeassistant.util.yaml - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.aiohttp --------------------------- - -.. automodule:: homeassistant.util.aiohttp - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.async\_ --------------------------- - -.. automodule:: homeassistant.util.async_ - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.color ------------------------- - -.. automodule:: homeassistant.util.color - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.decorator ----------------------------- - -.. automodule:: homeassistant.util.decorator - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.dt ---------------------- - -.. automodule:: homeassistant.util.dt - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.json ------------------------ - -.. automodule:: homeassistant.util.json - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.location ---------------------------- - -.. automodule:: homeassistant.util.location - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.logging --------------------------- - -.. automodule:: homeassistant.util.logging - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.network --------------------------- - -.. automodule:: homeassistant.util.network - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.package --------------------------- - -.. automodule:: homeassistant.util.package - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.pil ----------------------- - -.. automodule:: homeassistant.util.pil - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.ssl ----------------------- - -.. automodule:: homeassistant.util.ssl - :members: - :undoc-members: - :show-inheritance: - -homeassistant.util.unit\_system -------------------------------- - -.. automodule:: homeassistant.util.unit_system - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py deleted file mode 100644 index 3bd3baa39cc..00000000000 --- a/docs/source/conf.py +++ /dev/null @@ -1,438 +0,0 @@ -#!/usr/bin/env python3 -"""Home Assistant documentation build configuration file. - -This file is execfile()d with the current directory set to its -containing dir. - -Note that not all possible configuration values are present in this -autogenerated file. - -All configuration values have a default; values that are commented out -serve to show the default. - -If extensions (or modules to document with autodoc) are in another directory, -add these directories to sys.path here. If the directory is relative to the -documentation root, use os.path.abspath to make it absolute, like shown here. -""" - -import inspect -import os -import sys - -from homeassistant.const import __short_version__, __version__ - -PROJECT_NAME = "Home Assistant" -PROJECT_PACKAGE_NAME = "homeassistant" -PROJECT_AUTHOR = "The Home Assistant Authors" -PROJECT_COPYRIGHT = PROJECT_AUTHOR -PROJECT_LONG_DESCRIPTION = ( - "Home Assistant is an open-source " - "home automation platform running on Python 3. " - "Track and control all devices at home and " - "automate control. " - "Installation in less than a minute." -) -PROJECT_GITHUB_USERNAME = "home-assistant" -PROJECT_GITHUB_REPOSITORY = "home-assistant" - -GITHUB_PATH = f"{PROJECT_GITHUB_USERNAME}/{PROJECT_GITHUB_REPOSITORY}" -GITHUB_URL = f"https://github.com/{GITHUB_PATH}" - - -sys.path.insert(0, os.path.abspath("_ext")) -sys.path.insert(0, os.path.abspath("../homeassistant")) - -# -- General configuration ------------------------------------------------ - -# If your documentation needs a minimal Sphinx version, state it here. -# -# needs_sphinx = '1.0' - -# Add any Sphinx extension module names here, as strings. They can be -# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom -# ones. -extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.linkcode", - "sphinx_autodoc_annotation", - "edit_on_github", -] - -# Add any paths that contain templates here, relative to this directory. -templates_path = ["_templates"] - -# The suffix(es) of source filenames. -# You can specify multiple suffix as a list of string: -# -# source_suffix = ['.rst', '.md'] -source_suffix = ".rst" - -# The encoding of source files. -# -# source_encoding = 'utf-8-sig' - -# The master toctree document. -master_doc = "index" - -# General information about the project. -project = PROJECT_NAME -copyright = PROJECT_COPYRIGHT -author = PROJECT_AUTHOR - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = __short_version__ -# The full version, including alpha/beta/rc tags. -release = __version__ - -code_branch = "dev" if "dev" in __version__ else "master" - -# Edit on Github config -edit_on_github_project = GITHUB_PATH -edit_on_github_branch = code_branch -edit_on_github_src_path = "docs/source/" - - -def linkcode_resolve(domain, info): - """Determine the URL corresponding to Python object.""" - if domain != "py": - return None - modname = info["module"] - fullname = info["fullname"] - submod = sys.modules.get(modname) - if submod is None: - return None - obj = submod - for part in fullname.split("."): - try: - obj = getattr(obj, part) - except Exception: # pylint: disable=broad-except - return None - try: - fn = inspect.getsourcefile(obj) - except Exception: # pylint: disable=broad-except - fn = None - if not fn: - return None - try: - source, lineno = inspect.findsource(obj) - except Exception: # pylint: disable=broad-except - lineno = None - if lineno: - linespec = "#L%d" % (lineno + 1) - else: - linespec = "" - index = fn.find("/homeassistant/") - if index == -1: - index = 0 - - fn = fn[index:] - - return f"{GITHUB_URL}/blob/{code_branch}/{fn}{linespec}" - - -# The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -# -# This is also used if you do content translation via gettext catalogs. -# Usually you set "language" from the command line for these cases. -language = "en" - -# There are two options for replacing |today|: either, you set today to some -# non-false value, then it is used: -# -# today = '' -# -# Else, today_fmt is used as the format for a strftime call. -# -# today_fmt = '%B %d, %Y' - -# List of patterns, relative to source directory, that match files and -# directories to ignore when looking for source files. -# This patterns also effect to html_static_path and html_extra_path -exclude_patterns = [] - -# The reST default role (used for this markup: `text`) to use for all -# documents. -# -# default_role = None - -# If true, '()' will be appended to :func: etc. cross-reference text. -# -# add_function_parentheses = True - -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# -# add_module_names = True - -# If true, sectionauthor and moduleauthor directives will be shown in the -# output. They are ignored by default. -# -# show_authors = False - -# The name of the Pygments (syntax highlighting) style to use. -pygments_style = "sphinx" - -# A list of ignored prefixes for module index sorting. -# modindex_common_prefix = [] - -# If true, keep warnings as "system message" paragraphs in the built documents. -# keep_warnings = False - -# If true, `todo` and `todoList` produce output, else they produce nothing. -todo_include_todos = False - - -# -- Options for HTML output ---------------------------------------------- - -# The theme to use for HTML and HTML Help pages. See the documentation for -# a list of builtin themes. -# -html_theme = "alabaster" - -# Theme options are theme-specific and customize the look and feel of a theme -# further. For a list of options available for each theme, see the -# documentation. -# -html_theme_options = { - "logo": "logo.png", - "logo_name": PROJECT_NAME, - "description": PROJECT_LONG_DESCRIPTION, - "github_user": PROJECT_GITHUB_USERNAME, - "github_repo": PROJECT_GITHUB_REPOSITORY, - "github_type": "star", - "github_banner": True, - "touch_icon": "logo-apple.png", - # 'fixed_sidebar': True, # Re-enable when we have more content -} - -# Add any paths that contain custom themes here, relative to this directory. -# html_theme_path = [] - -# The name for this set of Sphinx documents. -# " v documentation" by default. -# -# html_title = 'Home-Assistant v0.27.0' - -# A shorter title for the navigation bar. Default is the same as html_title. -# -# html_short_title = None - -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -# -# html_logo = '_static/logo.png' - -# The name of an image file (relative to this directory) to use as a favicon of -# the docs. -# This file should be a Windows icon file (.ico) being 16x16 or 32x32 -# pixels large. -# -html_favicon = "_static/favicon.ico" - -# Add any paths that contain custom static files (such as style sheets) here, -# relative to this directory. They are copied after the builtin static files, -# so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ["_static"] - -# Add any extra paths that contain custom files (such as robots.txt or -# .htaccess) here, relative to this directory. These files are copied -# directly to the root of the documentation. -# -# html_extra_path = [] - -# If not None, a 'Last updated on:' timestamp is inserted at every page -# bottom, using the given strftime format. -# The empty string is equivalent to '%b %d, %Y'. -# -html_last_updated_fmt = "%b %d, %Y" - -# If true, SmartyPants will be used to convert quotes and dashes to -# typographically correct entities. -# -html_use_smartypants = True - -# Custom sidebar templates, maps document names to template names. -# -html_sidebars = { - "**": [ - "about.html", - "links.html", - "searchbox.html", - "sourcelink.html", - "navigation.html", - "relations.html", - ] -} - -# Additional templates that should be rendered to pages, maps page names to -# template names. -# -# html_additional_pages = {} - -# If false, no module index is generated. -# -# html_domain_indices = True - -# If false, no index is generated. -# -# html_use_index = True - -# If true, the index is split into individual pages for each letter. -# -# html_split_index = False - -# If true, links to the reST sources are added to the pages. -# -# html_show_sourcelink = True - -# If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -# -# html_show_sphinx = True - -# If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -# -# html_show_copyright = True - -# If true, an OpenSearch description file will be output, and all pages will -# contain a tag referring to it. The value of this option must be the -# base URL from which the finished HTML is served. -# -# html_use_opensearch = '' - -# This is the file name suffix for HTML files (e.g. ".xhtml"). -# html_file_suffix = None - -# Language to be used for generating the HTML full-text search index. -# Sphinx supports the following languages: -# 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' -# 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' -# -# html_search_language = 'en' - -# A dictionary with options for the search language support, empty by default. -# 'ja' uses this config value. -# 'zh' user can custom change `jieba` dictionary path. -# -# html_search_options = {'type': 'default'} - -# The name of a javascript file (relative to the configuration directory) that -# implements a search results scorer. If empty, the default will be used. -# -# html_search_scorer = 'scorer.js' - -# Output file base name for HTML help builder. -htmlhelp_basename = "Home-Assistantdoc" - -# -- Options for LaTeX output --------------------------------------------- - -latex_elements = { - # The paper size ('letterpaper' or 'a4paper'). - # - # 'papersize': 'letterpaper', - # The font size ('10pt', '11pt' or '12pt'). - # - # 'pointsize': '10pt', - # Additional stuff for the LaTeX preamble. - # - # 'preamble': '', - # Latex figure (float) alignment - # - # 'figure_align': 'htbp', -} - -# Grouping the document tree into LaTeX files. List of tuples -# (source start file, target name, title, -# author, documentclass [howto, manual, or own class]). -latex_documents = [ - ( - master_doc, - "home-assistant.tex", - "Home Assistant Documentation", - "Home Assistant Team", - "manual", - ) -] - -# The name of an image file (relative to this directory) to place at the top of -# the title page. -# -# latex_logo = None - -# For "manual" documents, if this is true, then toplevel headings are parts, -# not chapters. -# -# latex_use_parts = False - -# If true, show page references after internal links. -# -# latex_show_pagerefs = False - -# If true, show URL addresses after external links. -# -# latex_show_urls = False - -# Documents to append as an appendix to all manuals. -# -# latex_appendices = [] - -# It false, will not define \strong, \code, itleref, \crossref ... but only -# \sphinxstrong, ..., \sphinxtitleref, ... To help avoid clash with user added -# packages. -# -# latex_keep_old_macro_names = True - -# If false, no module index is generated. -# -# latex_domain_indices = True - - -# -- Options for manual page output --------------------------------------- - -# One entry per manual page. List of tuples -# (source start file, name, description, authors, manual section). -man_pages = [ - (master_doc, "home-assistant", "Home Assistant Documentation", [author], 1) -] - -# If true, show URL addresses after external links. -# -# man_show_urls = False - - -# -- Options for Texinfo output ------------------------------------------- - -# Grouping the document tree into Texinfo files. List of tuples -# (source start file, target name, title, author, -# dir menu entry, description, category) -texinfo_documents = [ - ( - master_doc, - "Home-Assistant", - "Home Assistant Documentation", - author, - "Home Assistant", - "Open-source home automation platform.", - "Miscellaneous", - ) -] - -# Documents to append as an appendix to all manuals. -# -# texinfo_appendices = [] - -# If false, no module index is generated. -# -# texinfo_domain_indices = True - -# How to display URL addresses: 'footnote', 'no', or 'inline'. -# -# texinfo_show_urls = 'footnote' - -# If true, do not generate a @detailmenu in the "Top" node's menu. -# -# texinfo_no_detailmenu = False diff --git a/docs/source/index.rst b/docs/source/index.rst deleted file mode 100644 index c592f66c070..00000000000 --- a/docs/source/index.rst +++ /dev/null @@ -1,22 +0,0 @@ -================================ -Home Assistant API Documentation -================================ - -Public API documentation for `Home Assistant developers`_. - -Contents: - -.. toctree:: - :maxdepth: 2 - :glob: - - api/* - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` - -.. _Home Assistant developers: https://developers.home-assistant.io/ diff --git a/requirements_docs.txt b/requirements_docs.txt deleted file mode 100644 index ef700013d1d..00000000000 --- a/requirements_docs.txt +++ /dev/null @@ -1,3 +0,0 @@ -Sphinx==7.2.6 -sphinx-autodoc-typehints==1.10.3 -sphinx-autodoc-annotation==1.0.post1 \ No newline at end of file From dcdd4b470c53e63f2544d7987e1c002b9976e09d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 08:11:33 +0200 Subject: [PATCH 587/968] Do not fail MQTT setup if lawn mowers configured via yaml can't be validated (#102314) --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/lawn_mower.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 4fb53db1031..b72391a55ae 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -18,7 +18,6 @@ from . import ( button as button_platform, cover as cover_platform, event as event_platform, - lawn_mower as lawn_mower_platform, number as number_platform, sensor as sensor_platform, update as update_platform, @@ -60,10 +59,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.FAN.value: vol.All(cv.ensure_list, [dict]), Platform.HUMIDIFIER.value: vol.All(cv.ensure_list, [dict]), Platform.IMAGE.value: vol.All(cv.ensure_list, [dict]), - Platform.LAWN_MOWER.value: vol.All( - cv.ensure_list, - [lawn_mower_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), Platform.NUMBER.value: vol.All( diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 68c7eda16ea..cd19e9c26ed 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable import contextlib -import functools import logging import voluptuous as vol @@ -20,7 +19,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA @@ -35,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -92,21 +91,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttLawnMower, + lawn_mower.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, lawn_mower.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT lawn mower.""" - async_add_entities([MqttLawnMower(hass, config, config_entry, discovery_data)]) class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): From 3285c982fe40c9cc556c7d18bda3ddfad765f2e8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 20 Oct 2023 02:11:49 -0400 Subject: [PATCH 588/968] Bump ZHA dependencies (#102358) --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 5cde71f8d07..a2b48655100 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.5", + "bellows==0.36.7", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.105", @@ -29,7 +29,7 @@ "zigpy==0.57.2", "zigpy-xbee==0.18.3", "zigpy-zigate==0.11.0", - "zigpy-znp==0.11.5", + "zigpy-znp==0.11.6", "universal-silabs-flasher==0.0.14", "pyserial-asyncio-fast==0.11" ], diff --git a/requirements_all.txt b/requirements_all.txt index 6398afafdca..1e8a6f52836 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.7 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.1 @@ -2810,7 +2810,7 @@ zigpy-xbee==0.18.3 zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.5 +zigpy-znp==0.11.6 # homeassistant.components.zha zigpy==0.57.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a19c88b895e..07f14083819 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.7 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.1 @@ -2098,7 +2098,7 @@ zigpy-xbee==0.18.3 zigpy-zigate==0.11.0 # homeassistant.components.zha -zigpy-znp==0.11.5 +zigpy-znp==0.11.6 # homeassistant.components.zha zigpy==0.57.2 From b911f242dd5d56717af7cd534197e35c6359d50f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 20:12:15 -1000 Subject: [PATCH 589/968] Use new lookup methods for homekit_controller (#102278) --- .../components/homekit_controller/entity.py | 17 ++++------------- 1 file changed, 4 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 796f227e0cc..5bf46eb44ce 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -3,14 +3,13 @@ from __future__ import annotations from typing import Any -from aiohomekit.model import Service, Services from aiohomekit.model.characteristics import ( EVENT_CHARACTERISTICS, Characteristic, CharacteristicPermissions, CharacteristicsTypes, ) -from aiohomekit.model.services import ServicesTypes +from aiohomekit.model.services import Service, ServicesTypes from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo @@ -21,14 +20,6 @@ from .connection import HKDevice, valid_serial_number from .utils import folded_name -def _get_service_by_iid_or_none(services: Services, iid: int) -> Service | None: - """Return a service by iid or None.""" - try: - return services.iid(iid) - except KeyError: - return None - - class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" @@ -68,9 +59,9 @@ class HomeKitEntity(Entity): def _async_remove_entity_if_accessory_or_service_disappeared(self) -> bool: """Handle accessory or service disappearance.""" entity_map = self._accessory.entity_map - if not entity_map.has_aid(self._aid) or not _get_service_by_iid_or_none( - entity_map.aid(self._aid).services, self._iid - ): + if not ( + accessory := entity_map.aid_or_none(self._aid) + ) or not accessory.services.iid_or_none(self._iid): self._async_handle_entity_removed() return True return False From bb90c1f1685e94125ff053a1b357cb771d8370a4 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 20 Oct 2023 08:12:53 +0200 Subject: [PATCH 590/968] Fix multilevel reference translations (#102338) --- .../components/airthings_ble/strings.json | 2 +- homeassistant/components/aranet/strings.json | 2 +- .../components/binary_sensor/strings.json | 4 ++-- .../components/bluemaestro/strings.json | 2 +- homeassistant/components/bthome/strings.json | 2 +- .../components/buienradar/strings.json | 6 ++--- .../components/derivative/strings.json | 2 +- .../components/dormakaba_dkey/strings.json | 2 +- homeassistant/components/energy/strings.json | 4 ++-- .../components/eufylife_ble/strings.json | 2 +- .../components/gardena_bluetooth/strings.json | 2 +- .../components/govee_ble/strings.json | 2 +- homeassistant/components/group/strings.json | 22 +++++++++---------- homeassistant/components/hassio/strings.json | 10 ++++----- .../homeassistant_sky_connect/strings.json | 6 ++--- .../homeassistant_yellow/strings.json | 6 ++--- .../components/humidifier/strings.json | 2 +- homeassistant/components/inkbird/strings.json | 2 +- .../components/input_text/strings.json | 4 ++-- homeassistant/components/kegtron/strings.json | 2 +- homeassistant/components/knx/strings.json | 12 +++++----- .../components/lametric/strings.json | 2 +- .../components/medcom_ble/strings.json | 2 +- homeassistant/components/moat/strings.json | 2 +- homeassistant/components/mopeka/strings.json | 2 +- homeassistant/components/oralb/strings.json | 2 +- .../components/plugwise/strings.json | 2 +- .../components/purpleair/strings.json | 4 ++-- .../components/qingping/strings.json | 2 +- .../components/rapt_ble/strings.json | 2 +- homeassistant/components/scrape/strings.json | 12 +++++----- .../components/sensorpro/strings.json | 2 +- .../components/sensorpush/strings.json | 2 +- homeassistant/components/snooz/strings.json | 2 +- homeassistant/components/sql/strings.json | 4 ++-- homeassistant/components/switch/strings.json | 2 +- .../components/template/strings.json | 4 ++-- .../components/thermobeacon/strings.json | 2 +- .../components/thermopro/strings.json | 2 +- .../components/threshold/strings.json | 2 +- .../components/tilt_ble/strings.json | 2 +- homeassistant/components/tuya/strings.json | 2 +- homeassistant/components/vulcan/strings.json | 2 +- .../components/xiaomi_ble/strings.json | 2 +- homeassistant/components/zha/strings.json | 8 +++---- 45 files changed, 84 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/airthings_ble/strings.json b/homeassistant/components/airthings_ble/strings.json index b1159e6f251..b7343377a2b 100644 --- a/homeassistant/components/airthings_ble/strings.json +++ b/homeassistant/components/airthings_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/aranet/strings.json b/homeassistant/components/aranet/strings.json index 1970beec210..918cfc1d384 100644 --- a/homeassistant/components/aranet/strings.json +++ b/homeassistant/components/aranet/strings.json @@ -4,7 +4,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index 573b154e2a4..29e40c8b336 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -247,8 +247,8 @@ "presence": { "name": "Presence", "state": { - "off": "[%key:component::device_tracker::entity_component::_::state::not_home%]", - "on": "[%key:component::device_tracker::entity_component::_::state::home%]" + "off": "[%key:common::state::not_home%]", + "on": "[%key:common::state::home%]" } }, "problem": { diff --git a/homeassistant/components/bluemaestro/strings.json b/homeassistant/components/bluemaestro/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/bluemaestro/strings.json +++ b/homeassistant/components/bluemaestro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/bthome/strings.json b/homeassistant/components/bthome/strings.json index 020a0206e73..39ba3baa3fd 100644 --- a/homeassistant/components/bthome/strings.json +++ b/homeassistant/components/bthome/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index 2141f420167..279d81f22ab 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -428,9 +428,9 @@ "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", - "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", - "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", - "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" } }, "conditiondetailed_5d": { diff --git a/homeassistant/components/derivative/strings.json b/homeassistant/components/derivative/strings.json index ef36d46d8b9..4b66c893d57 100644 --- a/homeassistant/components/derivative/strings.json +++ b/homeassistant/components/derivative/strings.json @@ -25,7 +25,7 @@ "step": { "init": { "data": { - "name": "[%key:component::derivative::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "round": "[%key:component::derivative::config::step::user::data::round%]", "source": "[%key:component::derivative::config::step::user::data::source%]", "time_window": "[%key:component::derivative::config::step::user::data::time_window%]", diff --git a/homeassistant/components/dormakaba_dkey/strings.json b/homeassistant/components/dormakaba_dkey/strings.json index 15bcf3f9ddc..480f021b126 100644 --- a/homeassistant/components/dormakaba_dkey/strings.json +++ b/homeassistant/components/dormakaba_dkey/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/energy/strings.json b/homeassistant/components/energy/strings.json index 9a72541bb50..4a9c1b4aacf 100644 --- a/homeassistant/components/energy/strings.json +++ b/homeassistant/components/energy/strings.json @@ -38,11 +38,11 @@ "description": "The following entities do not have an expected unit of measurement {price_units}:" }, "entity_unexpected_unit_gas_price": { - "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]", "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_unit_water_price": { - "title": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::title%]", + "title": "[%key:component::energy::issues::entity_unexpected_unit_energy::title%]", "description": "[%key:component::energy::issues::entity_unexpected_unit_energy_price::description%]" }, "entity_unexpected_state_class": { diff --git a/homeassistant/components/eufylife_ble/strings.json b/homeassistant/components/eufylife_ble/strings.json index 5f7924f4cbd..aaeeeb85f67 100644 --- a/homeassistant/components/eufylife_ble/strings.json +++ b/homeassistant/components/eufylife_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/gardena_bluetooth/strings.json b/homeassistant/components/gardena_bluetooth/strings.json index 01eac80d1e0..d0c1b878cef 100644 --- a/homeassistant/components/gardena_bluetooth/strings.json +++ b/homeassistant/components/gardena_bluetooth/strings.json @@ -4,7 +4,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "confirm": { diff --git a/homeassistant/components/govee_ble/strings.json b/homeassistant/components/govee_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/govee_ble/strings.json +++ b/homeassistant/components/govee_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 5f3042c5bf7..c5cebbc4707 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -32,7 +32,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "event": { @@ -40,7 +40,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "fan": { @@ -48,7 +48,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "light": { @@ -56,7 +56,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "lock": { @@ -64,7 +64,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "media_player": { @@ -72,7 +72,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } }, "sensor": { @@ -94,7 +94,7 @@ "data": { "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", - "name": "[%key:component::group::config::step::binary_sensor::data::name%]" + "name": "[%key:common::config_flow::data::name%]" } } } @@ -145,8 +145,8 @@ "description": "If \"ignore non-numeric\" is enabled, the group's state is calculated if at least one member has a numerical value. If \"ignore non-numeric\" is disabled, the group's state is calculated only if all group members have numerical values.", "data": { "ignore_non_numeric": "[%key:component::group::config::step::sensor::data::ignore_non_numeric%]", - "entities": "[%key:component::group::config::step::sensor::data::entities%]", - "hide_members": "[%key:component::group::config::step::sensor::data::hide_members%]", + "entities": "[%key:component::group::config::step::binary_sensor::data::entities%]", + "hide_members": "[%key:component::group::config::step::binary_sensor::data::hide_members%]", "type": "[%key:component::group::config::step::sensor::data::type%]", "round_digits": "[%key:component::group::config::step::sensor::data::round_digits%]", "device_class": "[%key:component::group::config::step::sensor::data::device_class%]", @@ -170,8 +170,8 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]", - "home": "[%key:component::device_tracker::entity_component::_::state::home%]", - "not_home": "[%key:component::device_tracker::entity_component::_::state::not_home%]", + "home": "[%key:common::state::home%]", + "not_home": "[%key:common::state::not_home%]", "open": "[%key:common::state::open%]", "closed": "[%key:common::state::closed%]", "locked": "[%key:common::state::locked%]", diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index c45d455631b..bdd94933b2b 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -316,11 +316,11 @@ "description": "List of directories to include in the backup." }, "name": { - "name": "[%key:component::hassio::services::backup_full::fields::name::name%]", + "name": "[%key:common::config_flow::data::name%]", "description": "[%key:component::hassio::services::backup_full::fields::name::description%]" }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "[%key:component::hassio::services::backup_full::fields::password::description%]" }, "compressed": { @@ -328,7 +328,7 @@ "description": "[%key:component::hassio::services::backup_full::fields::compressed::description%]" }, "location": { - "name": "[%key:component::hassio::services::backup_full::fields::location::name%]", + "name": "[%key:common::config_flow::data::location%]", "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" } } @@ -342,7 +342,7 @@ "description": "Slug of backup to restore from." }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "Optional password." } } @@ -368,7 +368,7 @@ "description": "[%key:component::hassio::services::backup_partial::fields::addons::description%]" }, "password": { - "name": "[%key:component::hassio::services::backup_full::fields::password::name%]", + "name": "[%key:common::config_flow::data::password%]", "description": "[%key:component::hassio::services::restore_full::fields::password::description%]" } } diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 2ed0026a48c..825649ef0d3 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -13,12 +13,12 @@ }, "addon_menu": { "menu_options": { - "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", - "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "change_channel": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", "data": { "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" }, diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 894d799d073..95442d31500 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -13,12 +13,12 @@ }, "addon_menu": { "menu_options": { - "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", - "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" } }, "change_channel": { - "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::title%]", + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", "data": { "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::change_channel::data::channel%]" }, diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 1cdad10f2fb..cb59dd04bdd 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -72,7 +72,7 @@ "name": "Dehumidifier" }, "humidifier": { - "name": "[%key:component::humidifier::entity_component::_::name%]" + "name": "[%key:component::humidifier::title%]" } }, "services": { diff --git a/homeassistant/components/inkbird/strings.json b/homeassistant/components/inkbird/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/inkbird/strings.json +++ b/homeassistant/components/inkbird/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/input_text/strings.json b/homeassistant/components/input_text/strings.json index 49eab33848c..13a86a329a7 100644 --- a/homeassistant/components/input_text/strings.json +++ b/homeassistant/components/input_text/strings.json @@ -18,10 +18,10 @@ "name": "[%key:component::text::entity_component::_::state_attributes::min::name%]" }, "mode": { - "name": "[%key:component::text::entity_component::_::state_attributes::mode::name%]", + "name": "[%key:common::config_flow::data::mode%]", "state": { "text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]", - "password": "[%key:component::text::entity_component::_::state_attributes::mode::state::password%]" + "password": "[%key:common::config_flow::data::password%]" } }, "pattern": { diff --git a/homeassistant/components/kegtron/strings.json b/homeassistant/components/kegtron/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/kegtron/strings.json +++ b/homeassistant/components/kegtron/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 8ffbcdf0566..5f5a2263eac 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -189,10 +189,10 @@ } }, "secure_key_source_menu_routing": { - "title": "[%key:component::knx::config::step::secure_key_source_menu_routing::title%]", - "description": "[%key:component::knx::config::step::secure_key_source_menu_routing::description%]", + "title": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::title%]", + "description": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::description%]", "menu_options": { - "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_knxkeys%]", + "secure_knxkeys": "[%key:component::knx::config::step::secure_key_source_menu_tunnel::menu_options::secure_knxkeys%]", "secure_routing_manual": "[%key:component::knx::config::step::secure_key_source_menu_routing::menu_options::secure_routing_manual%]" } }, @@ -230,7 +230,7 @@ }, "secure_routing_manual": { "title": "[%key:component::knx::config::step::secure_routing_manual::title%]", - "description": "[%key:component::knx::config::step::secure_routing_manual::description%]", + "description": "[%key:component::knx::config::step::secure_tunnel_manual::description%]", "data": { "backbone_key": "[%key:component::knx::config::step::secure_routing_manual::data::backbone_key%]", "sync_latency_tolerance": "[%key:component::knx::config::step::secure_routing_manual::data::sync_latency_tolerance%]" @@ -248,11 +248,11 @@ "routing_secure": "[%key:component::knx::config::step::routing::data::routing_secure%]", "multicast_group": "[%key:component::knx::config::step::routing::data::multicast_group%]", "multicast_port": "[%key:component::knx::config::step::routing::data::multicast_port%]", - "local_ip": "[%key:component::knx::config::step::routing::data::local_ip%]" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data::local_ip%]" }, "data_description": { "individual_address": "[%key:component::knx::config::step::routing::data_description::individual_address%]", - "local_ip": "[%key:component::knx::config::step::routing::data_description::local_ip%]" + "local_ip": "[%key:component::knx::config::step::manual_tunnel::data_description::local_ip%]" } } }, diff --git a/homeassistant/components/lametric/strings.json b/homeassistant/components/lametric/strings.json index 21d2bdc84bd..e7bfc059674 100644 --- a/homeassistant/components/lametric/strings.json +++ b/homeassistant/components/lametric/strings.json @@ -115,7 +115,7 @@ "description": "Displays a message with an optional icon on a LaMetric device.", "fields": { "device_id": { - "name": "[%key:component::lametric::services::chart::fields::device_id::name%]", + "name": "[%key:common::config_flow::data::device%]", "description": "The LaMetric device to display the message on." }, "message": { diff --git a/homeassistant/components/medcom_ble/strings.json b/homeassistant/components/medcom_ble/strings.json index 6ea6c0566ed..56cfb5a1dd7 100644 --- a/homeassistant/components/medcom_ble/strings.json +++ b/homeassistant/components/medcom_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/moat/strings.json b/homeassistant/components/moat/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/moat/strings.json +++ b/homeassistant/components/moat/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/mopeka/strings.json b/homeassistant/components/mopeka/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/mopeka/strings.json +++ b/homeassistant/components/mopeka/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/oralb/strings.json b/homeassistant/components/oralb/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/oralb/strings.json +++ b/homeassistant/components/oralb/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 82228ee94e7..5348a1dc484 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -65,7 +65,7 @@ "state": { "asleep": "Night", "away": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::away%]", - "home": "[%key:component::climate::entity_component::_::state_attributes::preset_mode::state::home%]", + "home": "[%key:common::state::home%]", "no_frost": "Anti-frost", "vacation": "Vacation" } diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json index ff505010713..b082e088ba2 100644 --- a/homeassistant/components/purpleair/strings.json +++ b/homeassistant/components/purpleair/strings.json @@ -56,8 +56,8 @@ "title": "Add Sensor", "description": "[%key:component::purpleair::config::step::by_coordinates::description%]", "data": { - "latitude": "[%key:component::purpleair::config::step::by_coordinates::data::latitude%]", - "longitude": "[%key:component::purpleair::config::step::by_coordinates::data::longitude%]", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", "distance": "[%key:component::purpleair::config::step::by_coordinates::data::distance%]" }, "data_description": { diff --git a/homeassistant/components/qingping/strings.json b/homeassistant/components/qingping/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/qingping/strings.json +++ b/homeassistant/components/qingping/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/rapt_ble/strings.json b/homeassistant/components/rapt_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/rapt_ble/strings.json +++ b/homeassistant/components/rapt_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 45f48c8401e..217e69b27df 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -65,7 +65,7 @@ }, "add_sensor": { "data": { - "name": "[%key:component::scrape::config::step::sensor::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", @@ -86,7 +86,7 @@ }, "edit_sensor": { "data": { - "name": "[%key:component::scrape::config::step::sensor::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "attribute": "[%key:component::scrape::config::step::sensor::data::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", @@ -111,10 +111,10 @@ "method": "[%key:component::scrape::config::step::user::data::method%]", "payload": "[%key:component::scrape::config::step::user::data::payload%]", "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", - "username": "[%key:component::scrape::config::step::user::data::username%]", - "password": "[%key:component::scrape::config::step::user::data::password%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", "headers": "[%key:component::scrape::config::step::user::data::headers%]", - "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "timeout": "[%key:component::scrape::config::step::user::data::timeout%]", "encoding": "[%key:component::scrape::config::step::user::data::encoding%]" }, @@ -175,7 +175,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", diff --git a/homeassistant/components/sensorpro/strings.json b/homeassistant/components/sensorpro/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/sensorpro/strings.json +++ b/homeassistant/components/sensorpro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/sensorpush/strings.json b/homeassistant/components/sensorpush/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/sensorpush/strings.json +++ b/homeassistant/components/sensorpush/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/snooz/strings.json b/homeassistant/components/snooz/strings.json index bc1e68db02f..b38e105260c 100644 --- a/homeassistant/components/snooz/strings.json +++ b/homeassistant/components/snooz/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/sql/strings.json b/homeassistant/components/sql/strings.json index 3289dfd41ff..b4bb73d4b99 100644 --- a/homeassistant/components/sql/strings.json +++ b/homeassistant/components/sql/strings.json @@ -38,7 +38,7 @@ "init": { "data": { "db_url": "[%key:component::sql::config::step::user::data::db_url%]", - "name": "[%key:component::sql::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "query": "[%key:component::sql::config::step::user::data::query%]", "column": "[%key:component::sql::config::step::user::data::column%]", "unit_of_measurement": "[%key:component::sql::config::step::user::data::unit_of_measurement%]", @@ -109,7 +109,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index b50709ed76f..0663384fe2c 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -25,7 +25,7 @@ } }, "switch": { - "name": "[%key:component::switch::entity_component::_::name%]" + "name": "[%key:component::switch::title%]" }, "outlet": { "name": "Outlet" diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 8e7dbaade97..19ad9e5ddeb 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -40,7 +40,7 @@ "sensor": { "data": { "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", - "state_class": "[%key:component::template::config::step::sensor::data::state_class%]", + "state_class": "[%key:component::sensor::entity_component::_::state_attributes::state_class::name%]", "state": "[%key:component::template::config::step::sensor::data::state%]", "unit_of_measurement": "[%key:component::template::config::step::sensor::data::unit_of_measurement%]" }, @@ -124,7 +124,7 @@ "temperature": "[%key:component::sensor::entity_component::temperature::name%]", "timestamp": "[%key:component::sensor::entity_component::timestamp::name%]", "volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", - "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds_parts::name%]", + "volatile_organic_compounds_parts": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]", "voltage": "[%key:component::sensor::entity_component::voltage::name%]", "volume": "[%key:component::sensor::entity_component::volume::name%]", "volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]", diff --git a/homeassistant/components/thermobeacon/strings.json b/homeassistant/components/thermobeacon/strings.json index a045d84771e..d1d544c2381 100644 --- a/homeassistant/components/thermobeacon/strings.json +++ b/homeassistant/components/thermobeacon/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/thermopro/strings.json b/homeassistant/components/thermopro/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/thermopro/strings.json +++ b/homeassistant/components/thermopro/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/threshold/strings.json b/homeassistant/components/threshold/strings.json index 832f3b4f899..fc9ee8fb7bf 100644 --- a/homeassistant/components/threshold/strings.json +++ b/homeassistant/components/threshold/strings.json @@ -26,7 +26,7 @@ "entity_id": "[%key:component::threshold::config::step::user::data::entity_id%]", "hysteresis": "[%key:component::threshold::config::step::user::data::hysteresis%]", "lower": "[%key:component::threshold::config::step::user::data::lower%]", - "name": "[%key:component::threshold::config::step::user::data::name%]", + "name": "[%key:common::config_flow::data::name%]", "upper": "[%key:component::threshold::config::step::user::data::upper%]" } } diff --git a/homeassistant/components/tilt_ble/strings.json b/homeassistant/components/tilt_ble/strings.json index 7111626cca1..4003debbbeb 100644 --- a/homeassistant/components/tilt_ble/strings.json +++ b/homeassistant/components/tilt_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index 1ea58f5029f..9c807419551 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -112,7 +112,7 @@ }, "number": { "temperature": { - "name": "[%key:component::number::entity_component::temperature::name%]" + "name": "[%key:component::sensor::entity_component::temperature::name%]" }, "time": { "name": "Time" diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 4ec58b3a06c..4af3ee95e35 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -29,7 +29,7 @@ "data": { "token": "Token", "region": "[%key:component::vulcan::config::step::auth::data::region%]", - "pin": "[%key:component::vulcan::config::step::auth::data::pin%]" + "pin": "[%key:common::config_flow::data::pin%]" } }, "select_student": { diff --git a/homeassistant/components/xiaomi_ble/strings.json b/homeassistant/components/xiaomi_ble/strings.json index 970de13bcef..d1bc6fa9a48 100644 --- a/homeassistant/components/xiaomi_ble/strings.json +++ b/homeassistant/components/xiaomi_ble/strings.json @@ -5,7 +5,7 @@ "user": { "description": "[%key:component::bluetooth::config::step::user::description%]", "data": { - "address": "[%key:component::bluetooth::config::step::user::data::address%]" + "address": "[%key:common::config_flow::data::device%]" } }, "bluetooth_confirm": { diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 56381d993b8..22c2810ad23 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -113,7 +113,7 @@ "data": { "radio_type": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]" }, - "title": "[%key:component::zha::config::step::manual_pick_radio_type::title%]", + "title": "[%key:component::zha::config::step::manual_pick_radio_type::data::radio_type%]", "description": "[%key:component::zha::config::step::manual_pick_radio_type::description%]" }, "manual_port_config": { @@ -163,11 +163,11 @@ } }, "error": { - "cannot_connect": "[%key:component::zha::config::error::cannot_connect%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_backup_json": "[%key:component::zha::config::error::invalid_backup_json%]" }, "abort": { - "single_instance_allowed": "[%key:component::zha::config::abort::single_instance_allowed%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "not_zha_device": "[%key:component::zha::config::abort::not_zha_device%]", "usb_probe_failed": "[%key:component::zha::config::abort::usb_probe_failed%]", "wrong_firmware_installed": "[%key:component::zha::config::abort::wrong_firmware_installed%]" @@ -621,7 +621,7 @@ }, "number": { "number": { - "name": "[%key:component::number::entity_component::_::name%]" + "name": "[%key:component::number::title%]" }, "detection_interval": { "name": "Detection interval" From 04fdcbe5fafa951b6bab99b2d17450aa61182fd8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 08:13:59 +0200 Subject: [PATCH 591/968] Do not fail MQTT setup if buttons configured via yaml can't be validated (#102301) --- homeassistant/components/mqtt/button.py | 28 +++++++------------ .../components/mqtt/config_integration.py | 6 +--- tests/components/mqtt/test_button.py | 3 +- 3 files changed, 12 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 47ac12386f7..a6bc43bece2 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -1,8 +1,6 @@ """Support for MQTT buttons.""" from __future__ import annotations -import functools - import voluptuous as vol from homeassistant.components import button @@ -12,7 +10,7 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( @@ -22,7 +20,7 @@ from .const import ( CONF_QOS, CONF_RETAIN, ) -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -50,21 +48,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttButton, + button.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, button.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT button.""" - async_add_entities([MqttButton(hass, config, config_entry, discovery_data)]) class MqttButton(MqttEntity, ButtonEntity): diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index b72391a55ae..a6ed1dae54d 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -15,7 +15,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from . import ( - button as button_platform, cover as cover_platform, event as event_platform, number as number_platform, @@ -41,10 +40,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( { Platform.ALARM_CONTROL_PANEL.value: vol.All(cv.ensure_list, [dict]), Platform.BINARY_SENSOR.value: vol.All(cv.ensure_list, [dict]), - Platform.BUTTON.value: vol.All( - cv.ensure_list, - [button_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.BUTTON.value: vol.All(cv.ensure_list, [dict]), Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All( diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 35b6561895d..f2f91c5ca75 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -456,8 +456,7 @@ async def test_invalid_device_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert "expected ButtonDeviceClass" in caplog.text From 38a0d31edb4021c12c1394330d72362120f26e66 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 20 Oct 2023 08:14:55 +0200 Subject: [PATCH 592/968] Bump pyduotecno to 2023.10.1 (#102344) --- 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 c7885496af8..96f76517a92 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", - "requirements": ["pyDuotecno==2023.10.0"] + "requirements": ["pyDuotecno==2023.10.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e8a6f52836..fcbacad0ea4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1546,7 +1546,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.0 +pyDuotecno==2023.10.1 # homeassistant.components.eight_sleep pyEight==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07f14083819..820c73be6ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1179,7 +1179,7 @@ pyCEC==0.5.2 pyControl4==1.1.0 # homeassistant.components.duotecno -pyDuotecno==2023.10.0 +pyDuotecno==2023.10.1 # homeassistant.components.eight_sleep pyEight==0.3.2 From c7b1e4186bbfd112b22b4ba74b3b69ed29684135 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 08:15:09 +0200 Subject: [PATCH 593/968] Do not fail MQTT setup if water heaters configured via yaml can't be validated (#102326) --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/water_heater.py | 27 +++++++------------ 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index a6ed1dae54d..4a8532682fb 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -20,7 +20,6 @@ from . import ( number as number_platform, sensor as sensor_platform, update as update_platform, - water_heater as water_heater_platform, ) from .const import ( CONF_BIRTH_MESSAGE, @@ -76,10 +75,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( [update_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] ), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), - Platform.WATER_HEATER.value: vol.All( - cv.ensure_list, - [water_heater_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } ) diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 9a9326d6d07..99cc35f74a4 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -1,7 +1,6 @@ """Support for MQTT water heater devices.""" from __future__ import annotations -import functools import logging from typing import Any @@ -38,7 +37,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from .climate import MqttTemperatureControlEntity @@ -67,7 +66,7 @@ from .const import ( from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -170,21 +169,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttWaterHeater, + water_heater.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, water_heater.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT water heater devices.""" - async_add_entities([MqttWaterHeater(hass, config, config_entry, discovery_data)]) class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): From 92e625636a12bf3cda940fe1c675f6fd61a7e029 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Fri, 20 Oct 2023 08:15:37 +0200 Subject: [PATCH 594/968] Fix ZHA `power_factor` attribute not initialized (#102133) --- .../components/zha/core/cluster_handlers/homeautomation.py | 1 + tests/components/zha/zha_devices_list.py | 5 ----- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py index 8ca014f453e..a379db54dac 100644 --- a/homeassistant/components/zha/core/cluster_handlers/homeautomation.py +++ b/homeassistant/components/zha/core/cluster_handlers/homeautomation.py @@ -87,6 +87,7 @@ class ElectricalMeasurementClusterHandler(ClusterHandler): "measurement_type": True, "power_divisor": True, "power_multiplier": True, + "power_factor": True, } async def async_update(self): diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 3be6322d1eb..842110ace87 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -2088,11 +2088,6 @@ DEVICES = [ DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementRMSVoltage", DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_voltage", }, - ("sensor", "00:11:22:33:44:55:66:77-1-2820-power_factor"): { - DEV_SIG_CLUSTER_HANDLERS: ["electrical_measurement"], - DEV_SIG_ENT_MAP_CLASS: "ElectricalMeasurementPowerFactor", - DEV_SIG_ENT_MAP_ID: "sensor.lumi_lumi_plug_maus01_power_factor", - }, ("sensor", "00:11:22:33:44:55:66:77-1-0-rssi"): { DEV_SIG_CLUSTER_HANDLERS: ["basic"], DEV_SIG_ENT_MAP_CLASS: "RSSISensor", From 8a79870e3a4a3b5336174e3411b123c48150237c Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Fri, 20 Oct 2023 02:48:33 -0400 Subject: [PATCH 595/968] Clean up stale ZHA database listener when reconnecting to radio (#101850) --- homeassistant/components/zha/__init__.py | 37 +++++++++------- homeassistant/components/zha/core/gateway.py | 45 +++++++++++--------- homeassistant/components/zha/core/helpers.py | 5 ++- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/test_init.py | 33 +++++++++++++- 7 files changed, 84 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 711ab2045eb..222c7f1d4ef 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -12,8 +12,8 @@ from zigpy.config import CONF_DATABASE, CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import NetworkSettingsInconsistent from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TYPE -from homeassistant.core import HomeAssistant +from homeassistant.const import CONF_TYPE, EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv @@ -160,19 +160,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) - async def async_zha_shutdown(): - """Handle shutdown tasks.""" - await zha_gateway.shutdown() - # clean up any remaining entity metadata - # (entities that have been discovered but not yet added to HA) - # suppress KeyError because we don't know what state we may - # be in when we get here in failure cases - with contextlib.suppress(KeyError): - for platform in PLATFORMS: - del zha_data.platforms[platform] - - config_entry.async_on_unload(async_zha_shutdown) - try: await zha_gateway.async_initialize() except NetworkSettingsInconsistent as exc: @@ -211,6 +198,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_load_api(hass) + async def async_shutdown(_: Event) -> None: + await zha_gateway.shutdown() + + config_entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown) + ) + await zha_gateway.async_initialize_devices_and_entities() await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) async_dispatcher_send(hass, SIGNAL_ADD_ENTITIES) @@ -220,7 +214,18 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" zha_data = get_zha_data(hass) - zha_data.gateway = None + + if zha_data.gateway is not None: + await zha_data.gateway.shutdown() + zha_data.gateway = None + + # clean up any remaining entity metadata + # (entities that have been discovered but not yet added to HA) + # suppress KeyError because we don't know what state we may + # be in when we get here in failure cases + with contextlib.suppress(KeyError): + for platform in PLATFORMS: + del zha_data.platforms[platform] GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 796a3c2dc05..b4c02d33015 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -203,28 +203,33 @@ class ZHAGateway: start_radio=False, ) - for attempt in range(STARTUP_RETRIES): - try: - await self.application_controller.startup(auto_form=True) - except NetworkSettingsInconsistent: - raise - except TransientConnectionError as exc: - raise ConfigEntryNotReady from exc - except Exception as exc: # pylint: disable=broad-except - _LOGGER.warning( - "Couldn't start %s coordinator (attempt %s of %s)", - self.radio_description, - attempt + 1, - STARTUP_RETRIES, - exc_info=exc, - ) + try: + for attempt in range(STARTUP_RETRIES): + try: + await self.application_controller.startup(auto_form=True) + except TransientConnectionError as exc: + raise ConfigEntryNotReady from exc + except NetworkSettingsInconsistent: + raise + except Exception as exc: # pylint: disable=broad-except + _LOGGER.debug( + "Couldn't start %s coordinator (attempt %s of %s)", + self.radio_description, + attempt + 1, + STARTUP_RETRIES, + exc_info=exc, + ) - if attempt == STARTUP_RETRIES - 1: - raise exc + if attempt == STARTUP_RETRIES - 1: + raise exc - await asyncio.sleep(STARTUP_FAILURE_DELAY_S) - else: - break + await asyncio.sleep(STARTUP_FAILURE_DELAY_S) + else: + break + except Exception: + # Explicitly shut down the controller application on failure + await self.application_controller.shutdown() + raise zha_data = get_zha_data(self.hass) zha_data.gateway = self diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 4df546b449c..cb9fadad00b 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -437,7 +437,10 @@ class ZHAData: def get_zha_data(hass: HomeAssistant) -> ZHAData: """Get the global ZHA data object.""" - return hass.data.get(DATA_ZHA, ZHAData()) + if DATA_ZHA not in hass.data: + hass.data[DATA_ZHA] = ZHAData() + + return hass.data[DATA_ZHA] def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index a2b48655100..c97eb608960 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -26,7 +26,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.105", "zigpy-deconz==0.21.1", - "zigpy==0.57.2", + "zigpy==0.58.1", "zigpy-xbee==0.18.3", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.6", diff --git a/requirements_all.txt b/requirements_all.txt index fcbacad0ea4..31018d1da88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2813,7 +2813,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.57.2 +zigpy==0.58.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 820c73be6ce..a24f78a6c1a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2101,7 +2101,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.57.2 +zigpy==0.58.1 # homeassistant.components.zwave_js zwave-js-server-python==0.52.1 diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index fc1e6611692..ad6ab4e351e 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -13,8 +13,14 @@ from homeassistant.components.zha.core.const import ( CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform -from homeassistant.core import HomeAssistant +from homeassistant.components.zha.core.helpers import get_zha_data +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + MAJOR_VERSION, + MINOR_VERSION, + Platform, +) +from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component @@ -203,3 +209,26 @@ async def test_zha_retry_unique_ids( await hass.config_entries.async_unload(config_entry.entry_id) assert "does not generate unique IDs" not in caplog.text + + +async def test_shutdown_on_ha_stop( + hass: HomeAssistant, + config_entry: MockConfigEntry, + mock_zigpy_connect: ControllerApplication, +) -> None: + """Test that the ZHA gateway is stopped when HA is shut down.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + zha_data = get_zha_data(hass) + + with patch.object( + zha_data.gateway, "shutdown", wraps=zha_data.gateway.shutdown + ) as mock_shutdown: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.state = CoreState.stopping + await hass.async_block_till_done() + + assert len(mock_shutdown.mock_calls) == 1 From 84d0907fc8f39f287bc47ee0a2059170db1e0b5d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 20 Oct 2023 08:49:22 +0200 Subject: [PATCH 596/968] Fix UniFi client tracker entities being unavailable when away on restart (#102125) --- homeassistant/components/unifi/controller.py | 21 +++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index fc803e3d800..b0ce43fe959 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -20,9 +20,14 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, device_registry as dr +from homeassistant.helpers import ( + aiohttp_client, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceEntryType, @@ -33,6 +38,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import async_entries_for_config_entry from homeassistant.helpers.event import async_call_later, async_track_time_interval import homeassistant.util.dt as dt_util @@ -131,7 +137,7 @@ class UniFiController: # Client control options # Config entry option with list of clients to control network access. - self.option_block_clients = options.get(CONF_BLOCK_CLIENT, []) + self.option_block_clients: list[str] = options.get(CONF_BLOCK_CLIENT, []) # Config entry option to control DPI restriction groups. self.option_dpi_restrictions: bool = options.get( CONF_DPI_RESTRICTIONS, DEFAULT_DPI_RESTRICTIONS @@ -244,7 +250,16 @@ class UniFiController: assert self.config_entry.unique_id is not None self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" - for mac in self.option_block_clients: + # Restore device tracker clients that are not a part of active clients list. + macs: list[str] = [] + entity_registry = er.async_get(self.hass) + for entry in async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ): + if entry.domain == Platform.DEVICE_TRACKER: + macs.append(entry.unique_id.split("-", 1)[0]) + + for mac in self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) From 3014a651c34e5873db3a1b6ccfc7add98696d0ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 19 Oct 2023 20:50:07 -1000 Subject: [PATCH 597/968] Reduce overhead to write HomeKit Controller state (#102365) --- .../homekit_controller/connection.py | 3 +- .../components/homekit_controller/entity.py | 37 +++++++++---------- .../homekit_controller/humidifier.py | 11 +++--- .../components/homekit_controller/sensor.py | 10 ++--- .../components/homekit_controller/utils.py | 2 + 5 files changed, 33 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 48bc3822001..923dfd8f96b 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -437,9 +437,10 @@ class HKDevice: @callback def async_migrate_unique_id( - self, old_unique_id: str, new_unique_id: str, platform: str + self, old_unique_id: str, new_unique_id: str | None, platform: str ) -> None: """Migrate legacy unique IDs to new format.""" + assert new_unique_id is not None _LOGGER.debug( "Checking if unique ID %s on %s needs to be migrated", old_unique_id, diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 5bf46eb44ce..7511f95e283 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -42,6 +42,7 @@ class HomeKitEntity(Entity): self._char_name: str | None = None self._char_subscription: CALLBACK_TYPE | None = None self.async_setup() + self._attr_unique_id = f"{accessory.unique_id}_{self._aid}_{self._iid}" super().__init__() @callback @@ -190,11 +191,6 @@ class HomeKitEntity(Entity): # Some accessories do not have a serial number return f"homekit-{self._accessory.unique_id}-{self._aid}-{self._iid}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}_{self._iid}" - @property def default_name(self) -> str | None: """Return the default name of the device.""" @@ -206,10 +202,9 @@ class HomeKitEntity(Entity): accessory_name = self.accessory.name # If the service has a name char, use that, if not # fallback to the default name provided by the subclass - device_name = self._char_name or self.default_name - folded_device_name = folded_name(device_name or "") - folded_accessory_name = folded_name(accessory_name) - if device_name: + if device_name := self._char_name or self.default_name: + folded_device_name = folded_name(device_name) + folded_accessory_name = folded_name(accessory_name) # Sometimes the device name includes the accessory # name already like My ecobee Occupancy / My ecobee if folded_device_name.startswith(folded_accessory_name): @@ -243,17 +238,17 @@ class HomeKitEntity(Entity): class AccessoryEntity(HomeKitEntity): """A HomeKit entity that is related to an entire accessory rather than a specific service or characteristic.""" + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise a generic HomeKit accessory.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_{self._aid}" + @property def old_unique_id(self) -> str: """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}" - class BaseCharacteristicEntity(HomeKitEntity): """A HomeKit entity that is related to an single characteristic rather than a whole service. @@ -299,13 +294,17 @@ class CharacteristicEntity(BaseCharacteristicEntity): the service entity. """ + def __init__( + self, accessory: HKDevice, devinfo: ConfigType, char: Characteristic + ) -> None: + """Initialise a generic single characteristic HomeKit entity.""" + super().__init__(accessory, devinfo, char) + self._attr_unique_id = ( + f"{accessory.unique_id}_{self._aid}_{char.service.iid}_{char.iid}" + ) + @property def old_unique_id(self) -> str: """Return the old ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-aid:{self._aid}-sid:{self._char.service.iid}-cid:{self._char.iid}" - - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._aid}_{self._char.service.iid}_{self._char.iid}" diff --git a/homeassistant/components/homekit_controller/humidifier.py b/homeassistant/components/homekit_controller/humidifier.py index cd2cf4022e7..57e4e7e73d8 100644 --- a/homeassistant/components/homekit_controller/humidifier.py +++ b/homeassistant/components/homekit_controller/humidifier.py @@ -19,6 +19,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType from . import KNOWN_DEVICES from .connection import HKDevice @@ -152,6 +153,11 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): _attr_device_class = HumidifierDeviceClass.DEHUMIDIFIER _attr_supported_features = HumidifierEntityFeature.MODES + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise the dehumidifier.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_{self._iid}_{self.device_class}" + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [ @@ -260,11 +266,6 @@ class HomeKitDehumidifier(HomeKitEntity, HumidifierEntity): serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-{self._iid}-{self.device_class}" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_{self._iid}_{self.device_class}" - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 1f17d32f912..2d30de24650 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -581,6 +581,11 @@ class RSSISensor(HomeKitEntity, SensorEntity): _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_should_poll = False + def __init__(self, accessory: HKDevice, devinfo: ConfigType) -> None: + """Initialise a HomeKit Controller RSSI sensor.""" + super().__init__(accessory, devinfo) + self._attr_unique_id = f"{accessory.unique_id}_rssi" + def get_characteristic_types(self) -> list[str]: """Define the homekit characteristics the entity cares about.""" return [] @@ -602,11 +607,6 @@ class RSSISensor(HomeKitEntity, SensorEntity): serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-rssi" - @property - def unique_id(self) -> str: - """Return the ID of this device.""" - return f"{self._accessory.unique_id}_rssi" - @property def native_value(self) -> int | None: """Return the current rssi value.""" diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index b43f1ee05f7..33a08504724 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -1,4 +1,5 @@ """Helper functions for the homekit_controller component.""" +from functools import lru_cache from typing import cast from aiohomekit import Controller @@ -11,6 +12,7 @@ from .const import CONTROLLER from .storage import async_get_entity_storage +@lru_cache def folded_name(name: str) -> str: """Return a name that is used for matching a similar string.""" return name.casefold().replace(" ", "") From f1eb28b7acd2c54b288e96c258c3ad73490f338c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 10:14:25 +0200 Subject: [PATCH 598/968] Do not fail MQTT setup if update entities configured via yaml can't be validated (#102324) --- .../components/mqtt/config_integration.py | 6 +--- homeassistant/components/mqtt/update.py | 29 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 4a8532682fb..def93075303 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -19,7 +19,6 @@ from . import ( event as event_platform, number as number_platform, sensor as sensor_platform, - update as update_platform, ) from .const import ( CONF_BIRTH_MESSAGE, @@ -70,10 +69,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), - Platform.UPDATE.value: vol.All( - cv.ensure_list, - [update_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.UPDATE.value: vol.All(cv.ensure_list, [dict]), Platform.VACUUM.value: vol.All(cv.ensure_list, [dict]), Platform.WATER_HEATER.value: vol.All(cv.ensure_list, [dict]), } diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index 45cca7279f9..c9ad17c078c 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -1,7 +1,6 @@ """Configure update platform in a device through MQTT topic.""" from __future__ import annotations -import functools import logging from typing import Any, TypedDict, cast @@ -19,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription @@ -36,7 +35,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage @@ -91,22 +90,16 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up MQTT update through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + """Set up MQTT update entity through YAML and through MQTT discovery.""" + await async_mqtt_entry_helper( + hass, + config_entry, + MqttUpdate, + update.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, update.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT update.""" - async_add_entities([MqttUpdate(hass, config, config_entry, discovery_data)]) class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): From 12c4a10cfcfba6c7eb5e69a58e1b2f7399546170 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 10:51:14 +0200 Subject: [PATCH 599/968] Do not fail MQTT setup if numbers configured via yaml can't be validated (#102316) Add number --- .../components/mqtt/config_integration.py | 6 +---- homeassistant/components/mqtt/number.py | 27 +++++++------------ tests/components/mqtt/test_number.py | 11 +++----- 3 files changed, 15 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index def93075303..5c254da0c27 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -17,7 +17,6 @@ from homeassistant.helpers import config_validation as cv from . import ( cover as cover_platform, event as event_platform, - number as number_platform, sensor as sensor_platform, ) from .const import ( @@ -56,10 +55,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.LAWN_MOWER.value: vol.All(cv.ensure_list, [dict]), Platform.LIGHT.value: vol.All(cv.ensure_list, [dict]), Platform.LOCK.value: vol.All(cv.ensure_list, [dict]), - Platform.NUMBER.value: vol.All( - cv.ensure_list, - [number_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), Platform.SENSOR.value: vol.All( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 231da95ffb0..34616df41bc 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging import voluptuous as vol @@ -28,7 +27,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA @@ -45,7 +44,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -118,21 +117,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttNumber, + number.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, number.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT number.""" - async_add_entities([MqttNumber(hass, config, config_entry, discovery_data)]) class MqttNumber(MqttEntity, RestoreNumber): diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index c6590c71c4d..f69b6e0730a 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -826,8 +826,7 @@ async def test_invalid_min_max_attributes( caplog: pytest.LogCaptureFixture, ) -> None: """Test invalid min/max attributes.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text @@ -948,11 +947,9 @@ async def test_invalid_mode( valid: bool, ) -> None: """Test invalid mode.""" - if valid: - await mqtt_mock_entry() - return - with pytest.raises(AssertionError): - await mqtt_mock_entry() + await mqtt_mock_entry() + state = hass.states.get("number.test_number") + assert (state is not None) == valid @pytest.mark.parametrize( From 1efbba2631f1c35be28a758aafad4ff6e630782f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 10:51:42 +0200 Subject: [PATCH 600/968] Do not fail MQTT setup if covers configured via yaml can't be validated (#102304) Add cover --- .../components/mqtt/config_integration.py | 6 +--- homeassistant/components/mqtt/cover.py | 27 +++++++---------- tests/components/mqtt/test_cover.py | 29 +++++++------------ 3 files changed, 21 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 5c254da0c27..3eca9a12e87 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -15,7 +15,6 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from . import ( - cover as cover_platform, event as event_platform, sensor as sensor_platform, ) @@ -40,10 +39,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.BUTTON.value: vol.All(cv.ensure_list, [dict]), Platform.CAMERA.value: vol.All(cv.ensure_list, [dict]), Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), - Platform.COVER.value: vol.All( - cv.ensure_list, - [cover_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.COVER.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), Platform.EVENT.value: vol.All( cv.ensure_list, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 39c4090109c..367390aefaf 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextlib import suppress -import functools import logging from typing import Any @@ -31,7 +30,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription @@ -48,7 +47,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -220,21 +219,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttCover, + cover.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, cover.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up the MQTT Cover.""" - async_add_entities([MqttCover(hass, config, config_entry, discovery_data)]) class MqttCover(MqttEntity, CoverEntity): diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 74dc48f4402..f3bf92951b0 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -891,11 +891,9 @@ async def test_optimistic_position( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test optimistic position is not supported.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( - "Invalid config for [mqtt]: 'set_position_topic' must be set together with 'position_topic'" - in caplog.text + "'set_position_topic' must be set together with 'position_topic'" in caplog.text ) @@ -2663,9 +2661,8 @@ async def test_invalid_device_class( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test the setting of an invalid device class.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert "Invalid config for [mqtt]: expected CoverDeviceClass" in caplog.text + assert await mqtt_mock_entry() + assert "expected CoverDeviceClass" in caplog.text async def test_setting_attribute_via_mqtt_json_message( @@ -3402,8 +3399,7 @@ async def test_set_position_topic_without_get_position_topic_error( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when set_position_topic is used without position_topic.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text @@ -3429,8 +3425,7 @@ async def test_value_template_without_state_topic_error( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when value_template is used and state_topic is missing.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text @@ -3456,8 +3451,7 @@ async def test_position_template_without_position_topic_error( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when position_template is used and position_topic is missing.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." in caplog.text @@ -3484,8 +3478,7 @@ async def test_set_position_template_without_set_position_topic( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when set_position_template is used and set_position_topic is missing.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." in caplog.text @@ -3512,8 +3505,7 @@ async def test_tilt_command_template_without_tilt_command_topic( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when tilt_command_template is used and tilt_command_topic is missing.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." in caplog.text @@ -3540,8 +3532,7 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( mqtt_mock_entry: MqttMockHAClientGenerator, ) -> None: """Test error when tilt_status_template is used and tilt_status_topic is missing.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() + assert await mqtt_mock_entry() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." in caplog.text From 554ab94782d2b814074f18dcf80179eed5cee61d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 11:13:18 +0200 Subject: [PATCH 601/968] Bump toonapi to 0.3.0 (#102369) --- homeassistant/components/toon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json index 67c36e92c78..5e5af394074 100644 --- a/homeassistant/components/toon/manifest.json +++ b/homeassistant/components/toon/manifest.json @@ -14,5 +14,5 @@ "documentation": "https://www.home-assistant.io/integrations/toon", "iot_class": "cloud_push", "loggers": ["toonapi"], - "requirements": ["toonapi==0.2.1"] + "requirements": ["toonapi==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 31018d1da88..95a82d727d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2594,7 +2594,7 @@ todoist-api-python==2.1.2 tololib==0.1.0b4 # homeassistant.components.toon -toonapi==0.2.1 +toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2023.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a24f78a6c1a..287877810af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1918,7 +1918,7 @@ todoist-api-python==2.1.2 tololib==0.1.0b4 # homeassistant.components.toon -toonapi==0.2.1 +toonapi==0.3.0 # homeassistant.components.totalconnect total-connect-client==2023.2 From 4e638239705b54b83ffd56baf10442cccb543806 Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Fri, 20 Oct 2023 12:02:43 +0200 Subject: [PATCH 602/968] Rename Twitter to X (#102214) --- homeassistant/components/nextdns/strings.json | 2 +- homeassistant/components/twitter/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- tests/components/nextdns/test_switch.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 2f15c4cd8e5..e0a37aad03b 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -275,7 +275,7 @@ "name": "Block Twitch" }, "block_twitter": { - "name": "Block Twitter" + "name": "Block X (formerly Twitter)" }, "block_video_streaming": { "name": "Block video streaming" diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json index be4af8d5ae6..44e8712b029 100644 --- a/homeassistant/components/twitter/manifest.json +++ b/homeassistant/components/twitter/manifest.json @@ -1,6 +1,6 @@ { "domain": "twitter", - "name": "Twitter", + "name": "X", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/twitter", "iot_class": "cloud_push", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ba426786dff..fb42c7f0e8e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6028,7 +6028,7 @@ "iot_class": "cloud_polling" }, "twitter": { - "name": "Twitter", + "name": "X", "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push" diff --git a/tests/components/nextdns/test_switch.py b/tests/components/nextdns/test_switch.py index 33a3f804902..9a360a24b63 100644 --- a/tests/components/nextdns/test_switch.py +++ b/tests/components/nextdns/test_switch.py @@ -513,11 +513,11 @@ async def test_switch( assert entry assert entry.unique_id == "xyz12_block_twitch" - state = hass.states.get("switch.fake_profile_block_twitter") + state = hass.states.get("switch.fake_profile_block_x_formerly_twitter") assert state assert state.state == STATE_ON - entry = registry.async_get("switch.fake_profile_block_twitter") + entry = registry.async_get("switch.fake_profile_block_x_formerly_twitter") assert entry assert entry.unique_id == "xyz12_block_twitter" From f85b4f734c9294f46f645db63897108e2a3519c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 20 Oct 2023 12:09:38 +0200 Subject: [PATCH 603/968] Implement Airzone Cloud Installation climate support (#101090) Co-authored-by: J. Nick Koston --- .../components/airzone_cloud/climate.py | 217 +++++++++++------- .../components/airzone_cloud/entity.py | 43 ++++ .../components/airzone_cloud/test_climate.py | 123 ++++++++++ 3 files changed, 297 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 89e528a0fbf..8995967f9a0 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -16,10 +16,12 @@ from aioairzone_cloud.const import ( AZD_AIDOOS, AZD_GROUPS, AZD_HUMIDITY, + AZD_INSTALLATIONS, AZD_MASTER, AZD_MODE, AZD_MODES, AZD_NUM_DEVICES, + AZD_NUM_GROUPS, AZD_POWER, AZD_TEMP, AZD_TEMP_SET, @@ -47,6 +49,7 @@ from .entity import ( AirzoneAidooEntity, AirzoneEntity, AirzoneGroupEntity, + AirzoneInstallationEntity, AirzoneZoneEntity, ) @@ -112,6 +115,17 @@ async def async_setup_entry( ) ) + # Installations + for inst_id, inst_data in coordinator.data.get(AZD_INSTALLATIONS, {}).items(): + if inst_data[AZD_NUM_GROUPS] > 1: + entities.append( + AirzoneInstallationClimate( + coordinator, + inst_id, + inst_data, + ) + ) + # Zones for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): entities.append( @@ -133,6 +147,34 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE _attr_temperature_unit = UnitOfTemperature.CELSIUS + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update climate attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) + self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ + self.get_airzone_value(AZD_ACTION) + ] + if self.get_airzone_value(AZD_POWER): + self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ + self.get_airzone_value(AZD_MODE) + ] + else: + self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + + +class AirzoneDeviceClimate(AirzoneClimate): + """Define an Airzone Cloud Device base class.""" + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -163,92 +205,9 @@ class AirzoneClimate(AirzoneEntity, ClimateEntity): } await self._async_update_params(params) - @callback - def _handle_coordinator_update(self) -> None: - """Update attributes when the coordinator updates.""" - self._async_update_attrs() - super()._handle_coordinator_update() - @callback - def _async_update_attrs(self) -> None: - """Update climate attributes.""" - self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) - self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY) - self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[ - self.get_airzone_value(AZD_ACTION) - ] - if self.get_airzone_value(AZD_POWER): - self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[ - self.get_airzone_value(AZD_MODE) - ] - else: - self._attr_hvac_mode = HVACMode.OFF - self._attr_max_temp = self.get_airzone_value(AZD_TEMP_SET_MAX) - self._attr_min_temp = self.get_airzone_value(AZD_TEMP_SET_MIN) - self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) - - -class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneClimate): - """Define an Airzone Cloud Aidoo climate.""" - - def __init__( - self, - coordinator: AirzoneUpdateCoordinator, - aidoo_id: str, - aidoo_data: dict, - ) -> None: - """Initialize Airzone Cloud Aidoo climate.""" - super().__init__(coordinator, aidoo_id, aidoo_data) - - self._attr_unique_id = aidoo_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] - - self._async_update_attrs() - - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set hvac mode.""" - params: dict[str, Any] = {} - if hvac_mode == HVACMode.OFF: - params[API_POWER] = { - API_VALUE: False, - } - else: - mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] - params[API_MODE] = { - API_VALUE: mode.value, - } - params[API_POWER] = { - API_VALUE: True, - } - await self._async_update_params(params) - - -class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneClimate): - """Define an Airzone Cloud Group climate.""" - - def __init__( - self, - coordinator: AirzoneUpdateCoordinator, - group_id: str, - group_data: dict, - ) -> None: - """Initialize Airzone Cloud Group climate.""" - super().__init__(coordinator, group_id, group_data) - - self._attr_unique_id = group_id - self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) - self._attr_hvac_modes = [ - HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) - ] - if HVACMode.OFF not in self._attr_hvac_modes: - self._attr_hvac_modes += [HVACMode.OFF] - - self._async_update_attrs() +class AirzoneDeviceGroupClimate(AirzoneClimate): + """Define an Airzone Cloud DeviceGroup base class.""" async def async_turn_on(self) -> None: """Turn the entity on.""" @@ -294,7 +253,93 @@ class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneClimate): await self._async_update_params(params) -class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneClimate): +class AirzoneAidooClimate(AirzoneAidooEntity, AirzoneDeviceClimate): + """Define an Airzone Cloud Aidoo climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + aidoo_id: str, + aidoo_data: dict, + ) -> None: + """Initialize Airzone Cloud Aidoo climate.""" + super().__init__(coordinator, aidoo_id, aidoo_data) + + self._attr_unique_id = aidoo_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + params: dict[str, Any] = {} + if hvac_mode == HVACMode.OFF: + params[API_POWER] = { + API_VALUE: False, + } + else: + mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] + params[API_MODE] = { + API_VALUE: mode.value, + } + params[API_POWER] = { + API_VALUE: True, + } + await self._async_update_params(params) + + +class AirzoneGroupClimate(AirzoneGroupEntity, AirzoneDeviceGroupClimate): + """Define an Airzone Cloud Group climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + group_id: str, + group_data: dict, + ) -> None: + """Initialize Airzone Cloud Group climate.""" + super().__init__(coordinator, group_id, group_data) + + self._attr_unique_id = group_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + +class AirzoneInstallationClimate(AirzoneInstallationEntity, AirzoneDeviceGroupClimate): + """Define an Airzone Cloud Installation climate.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + inst_id: str, + inst_data: dict, + ) -> None: + """Initialize Airzone Cloud Installation climate.""" + super().__init__(coordinator, inst_id, inst_data) + + self._attr_unique_id = inst_id + self._attr_target_temperature_step = self.get_airzone_value(AZD_TEMP_STEP) + self._attr_hvac_modes = [ + HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) + ] + if HVACMode.OFF not in self._attr_hvac_modes: + self._attr_hvac_modes += [HVACMode.OFF] + + self._async_update_attrs() + + +class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): """Define an Airzone Cloud Zone climate.""" def __init__( diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py index 749d4615e65..d5dd0cfcfb4 100644 --- a/homeassistant/components/airzone_cloud/entity.py +++ b/homeassistant/components/airzone_cloud/entity.py @@ -10,6 +10,7 @@ from aioairzone_cloud.const import ( AZD_AVAILABLE, AZD_FIRMWARE, AZD_GROUPS, + AZD_INSTALLATIONS, AZD_NAME, AZD_SYSTEM_ID, AZD_SYSTEMS, @@ -132,6 +133,48 @@ class AirzoneGroupEntity(AirzoneEntity): self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) +class AirzoneInstallationEntity(AirzoneEntity): + """Define an Airzone Cloud Installation entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + inst_id: str, + inst_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.inst_id = inst_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, inst_id)}, + manufacturer=MANUFACTURER, + name=inst_data[AZD_NAME], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Installation value by key.""" + value = None + if inst := self.coordinator.data[AZD_INSTALLATIONS].get(self.inst_id): + value = inst.get(key) + return value + + async def _async_update_params(self, params: dict[str, Any]) -> None: + """Send Installation parameters to Cloud API.""" + _LOGGER.debug("installation=%s: update_params=%s", self.name, params) + try: + await self.coordinator.airzone.api_set_installation_id_params( + self.inst_id, params + ) + except AirzoneCloudError as error: + raise HomeAssistantError( + f"Failed to set {self.name} params: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + + class AirzoneSystemEntity(AirzoneEntity): """Define an Airzone Cloud System entity.""" diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 56c563a8680..34336866339 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -74,6 +74,25 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + # Installations + state = hass.states.get("climate.house") + assert state.state == HVACMode.COOL + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + HVACMode.DRY, + HVACMode.OFF, + ] + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 23.3 + # Zones state = hass.states.get("climate.dormitorio") assert state.state == HVACMode.OFF @@ -165,6 +184,39 @@ async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: state = hass.states.get("climate.group") assert state.state == HVACMode.OFF + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "climate.house", + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.COOL + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "climate.house", + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -274,6 +326,41 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: state = hass.states.get("climate.group") assert state.state == HVACMode.OFF + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.DRY, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.DRY + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_HVAC_MODE: HVACMode.OFF, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.state == HVACMode.OFF + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -356,6 +443,24 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: state = hass.states.get("climate.group") assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + return_value=None, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.attributes[ATTR_TEMPERATURE] == 20.5 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", @@ -416,6 +521,24 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: state = hass.states.get("climate.group") assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + # Installations + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_put_installation", + side_effect=AirzoneCloudError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.house", + ATTR_TEMPERATURE: 20.5, + }, + blocking=True, + ) + + state = hass.states.get("climate.house") + assert state.attributes[ATTR_TEMPERATURE] == 23.3 + # Zones with patch( "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_patch_device", From 7d9014ae417ed4fc0ef5785f407da3204ce9568d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 12:09:52 +0200 Subject: [PATCH 604/968] Do not fail MQTT setup if events or sensors configured via yaml can't be validated (#102309) * Add event and sensor * Cleanup unused code * Schema cannot be None for supported platform --- homeassistant/components/mqtt/__init__.py | 3 +-- .../components/mqtt/config_integration.py | 14 ++-------- homeassistant/components/mqtt/event.py | 27 +++++++------------ homeassistant/components/mqtt/mixins.py | 25 +---------------- homeassistant/components/mqtt/sensor.py | 27 +++++++------------ tests/components/mqtt/test_common.py | 10 ++++--- tests/components/mqtt/test_event.py | 13 +++++---- tests/components/mqtt/test_sensor.py | 20 ++++++-------- 8 files changed, 44 insertions(+), 95 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 3d3bb486c02..9f3fe6ef72b 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -235,8 +235,7 @@ async def async_check_config_schema( mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml[DOMAIN] for mqtt_config_item in mqtt_config: for domain, config_items in mqtt_config_item.items(): - if (schema := mqtt_data.reload_schema.get(domain)) is None: - continue + schema = mqtt_data.reload_schema[domain] for config in config_items: try: schema(config) diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py index 3eca9a12e87..71260dc0239 100644 --- a/homeassistant/components/mqtt/config_integration.py +++ b/homeassistant/components/mqtt/config_integration.py @@ -14,10 +14,6 @@ from homeassistant.const import ( ) from homeassistant.helpers import config_validation as cv -from . import ( - event as event_platform, - sensor as sensor_platform, -) from .const import ( CONF_BIRTH_MESSAGE, CONF_BROKER, @@ -41,10 +37,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.CLIMATE.value: vol.All(cv.ensure_list, [dict]), Platform.COVER.value: vol.All(cv.ensure_list, [dict]), Platform.DEVICE_TRACKER.value: vol.All(cv.ensure_list, [dict]), - Platform.EVENT.value: vol.All( - cv.ensure_list, - [event_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.EVENT.value: vol.All(cv.ensure_list, [dict]), Platform.FAN.value: vol.All(cv.ensure_list, [dict]), Platform.HUMIDIFIER.value: vol.All(cv.ensure_list, [dict]), Platform.IMAGE.value: vol.All(cv.ensure_list, [dict]), @@ -54,10 +47,7 @@ CONFIG_SCHEMA_BASE = vol.Schema( Platform.NUMBER.value: vol.All(cv.ensure_list, [dict]), Platform.SCENE.value: vol.All(cv.ensure_list, [dict]), Platform.SELECT.value: vol.All(cv.ensure_list, [dict]), - Platform.SENSOR.value: vol.All( - cv.ensure_list, - [sensor_platform.PLATFORM_SCHEMA_MODERN], # type: ignore[has-type] - ), + Platform.SENSOR.value: vol.All(cv.ensure_list, [dict]), Platform.SIREN.value: vol.All(cv.ensure_list, [dict]), Platform.SWITCH.value: vol.All(cv.ensure_list, [dict]), Platform.TEXT.value: vol.All(cv.ensure_list, [dict]), diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index c345655eea5..39057314508 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -import functools import logging from typing import Any @@ -19,7 +18,7 @@ from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLAT from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription @@ -35,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -83,21 +82,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttEvent, + event.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, event.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up MQTT event.""" - async_add_entities([MqttEvent(hass, config, config_entry, discovery_data)]) class MqttEvent(MqttEntity, EventEntity): diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index ddc2703d820..2b86d8b3e87 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -import asyncio from collections.abc import Callable, Coroutine import functools from functools import partial, wraps @@ -312,7 +311,7 @@ async def async_setup_entry_helper( async_setup: partial[Coroutine[Any, Any, None]], discovery_schema: vol.Schema, ) -> None: - """Set up entity, automation or tag creation dynamically through MQTT discovery.""" + """Set up automation or tag creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) async def async_setup_from_discovery( @@ -332,28 +331,6 @@ async def async_setup_entry_helper( ) ) - # The setup of manual configured MQTT entities will be migrated to async_mqtt_entry_helper. - # The following setup code will be cleaned up after the last entity platform has been migrated. - async def _async_setup_entities() -> None: - """Set up MQTT items from configuration.yaml.""" - mqtt_data = get_mqtt_data(hass) - if not (config_yaml := mqtt_data.config): - return - setups: list[Coroutine[Any, Any, None]] = [ - async_setup(config) - for config_item in config_yaml - for config_domain, configs in config_item.items() - for config in configs - if config_domain == domain - ] - if not setups: - return - await asyncio.gather(*setups) - - # discover manual configured MQTT items - mqtt_data.reload_handlers[domain] = _async_setup_entities - await _async_setup_entities() - async def async_mqtt_entry_helper( hass: HomeAssistant, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 0f73b93f1de..a4a59de4dfb 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -import functools import logging from typing import Any @@ -33,7 +32,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util from . import subscription @@ -44,7 +43,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_setup_entry_helper, + async_mqtt_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -106,21 +105,15 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" - setup = functools.partial( - _async_setup_entity, hass, async_add_entities, config_entry=config_entry + await async_mqtt_entry_helper( + hass, + config_entry, + MqttSensor, + sensor.DOMAIN, + async_add_entities, + DISCOVERY_SCHEMA, + PLATFORM_SCHEMA_MODERN, ) - await async_setup_entry_helper(hass, sensor.DOMAIN, setup, DISCOVERY_SCHEMA) - - -async def _async_setup_entity( - hass: HomeAssistant, - async_add_entities: AddEntitiesCallback, - config: ConfigType, - config_entry: ConfigEntry, - discovery_data: DiscoveryInfoType | None = None, -) -> None: - """Set up MQTT sensor.""" - async_add_entities([MqttSensor(hass, config, config_entry, discovery_data)]) class MqttSensor(MqttEntity, RestoreSensor): diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 7af7aa34647..0664f6e8d6f 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -9,6 +9,7 @@ from typing import Any from unittest.mock import ANY, MagicMock, patch import pytest +import voluptuous as vol import yaml from homeassistant import config as module_hass_config @@ -363,6 +364,7 @@ async def help_test_default_availability_list_payload_any( async def help_test_default_availability_list_single( hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, domain: str, config: ConfigType, @@ -378,10 +380,10 @@ async def help_test_default_availability_list_single( ] config[mqtt.DOMAIN][domain]["availability_topic"] = "availability-topic" - entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) - entry.add_to_hass(hass) - with patch("homeassistant.config.load_yaml_config_file", return_value=config): - await entry.async_setup(hass) + with patch( + "homeassistant.config.load_yaml_config_file", return_value=config + ), suppress(vol.MultipleInvalid): + await mqtt_mock_entry() assert ( "two or more values in the same group of exclusion 'availability'" diff --git a/tests/components/mqtt/test_event.py b/tests/components/mqtt/test_event.py index 37a17ac9a41..4c0e63fec1f 100644 --- a/tests/components/mqtt/test_event.py +++ b/tests/components/mqtt/test_event.py @@ -224,11 +224,13 @@ async def test_default_availability_list_payload_any( async def test_default_availability_list_single( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, caplog, event.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, event.DOMAIN, DEFAULT_CONFIG ) @@ -271,11 +273,8 @@ async def test_invalid_device_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected EventDeviceClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected EventDeviceClass or one of" in caplog.text async def test_setting_attribute_via_mqtt_json_message( diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 06967b7f8a8..0f1be02875c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -708,11 +708,13 @@ async def test_default_availability_list_payload_any( async def test_default_availability_list_single( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, ) -> None: """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) @@ -754,11 +756,8 @@ async def test_invalid_device_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test device_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected SensorDeviceClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected SensorDeviceClass or one of" in caplog.text @pytest.mark.parametrize( @@ -818,11 +817,8 @@ async def test_invalid_state_class( caplog: pytest.LogCaptureFixture, ) -> None: """Test state_class option with invalid value.""" - with pytest.raises(AssertionError): - await mqtt_mock_entry() - assert ( - "Invalid config for [mqtt]: expected SensorStateClass or one of" in caplog.text - ) + assert await mqtt_mock_entry() + assert "expected SensorStateClass or one of" in caplog.text @pytest.mark.parametrize( From e319b04fdebcc4657ed2a560e072c83bd11e166c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 20 Oct 2023 12:58:31 +0200 Subject: [PATCH 605/968] Improve Airzone Cloud tests (#102377) --- .../components/airzone_cloud/test_climate.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/tests/components/airzone_cloud/test_climate.py b/tests/components/airzone_cloud/test_climate.py index 34336866339..4106b1af1e9 100644 --- a/tests/components/airzone_cloud/test_climate.py +++ b/tests/components/airzone_cloud/test_climate.py @@ -41,9 +41,9 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: state = hass.states.get("climate.bron") assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.0 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT, @@ -51,28 +51,28 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 22.0 # Groups state = hass.states.get("climate.group") assert state.state == HVACMode.COOL - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 27 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.5 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 27 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 22.5 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 # Installations state = hass.states.get("climate.house") @@ -96,37 +96,37 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: # Zones state = hass.states.get("climate.dormitorio") assert state.state == HVACMode.OFF - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 24 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 25.0 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 24 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 25.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.OFF + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 state = hass.states.get("climate.salon") assert state.state == HVACMode.COOL - assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 30 - assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.0 - assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING - assert state.attributes.get(ATTR_HVAC_MODES) == [ + assert state.attributes[ATTR_CURRENT_HUMIDITY] == 30 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 20.0 + assert state.attributes[ATTR_HVAC_ACTION] == HVACAction.COOLING + assert state.attributes[ATTR_HVAC_MODES] == [ HVACMode.COOL, HVACMode.HEAT, HVACMode.FAN_ONLY, HVACMode.DRY, HVACMode.OFF, ] - assert state.attributes.get(ATTR_MAX_TEMP) == 30 - assert state.attributes.get(ATTR_MIN_TEMP) == 15 - assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_MAX_TEMP] == 30 + assert state.attributes[ATTR_MIN_TEMP] == 15 + assert state.attributes[ATTR_TARGET_TEMP_STEP] == API_TEMPERATURE_STEP + assert state.attributes[ATTR_TEMPERATURE] == 24.0 async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: @@ -441,7 +441,7 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.group") - assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + assert state.attributes[ATTR_TEMPERATURE] == 20.5 # Installations with patch( @@ -477,7 +477,7 @@ async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.salon") - assert state.attributes.get(ATTR_TEMPERATURE) == 20.5 + assert state.attributes[ATTR_TEMPERATURE] == 20.5 async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: @@ -501,7 +501,7 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.bron") - assert state.attributes.get(ATTR_TEMPERATURE) == 22.0 + assert state.attributes[ATTR_TEMPERATURE] == 22.0 # Groups with patch( @@ -519,7 +519,7 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.group") - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_TEMPERATURE] == 24.0 # Installations with patch( @@ -555,4 +555,4 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: ) state = hass.states.get("climate.salon") - assert state.attributes.get(ATTR_TEMPERATURE) == 24.0 + assert state.attributes[ATTR_TEMPERATURE] == 24.0 From 25ab622b51ac9a207c582abfe026e070b37a31bf Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 13:36:24 +0200 Subject: [PATCH 606/968] Rename mqtt entry setup helpers to reflect their purpose (#102378) Rename mqtt entry setup helpers --- homeassistant/components/mqtt/alarm_control_panel.py | 4 ++-- homeassistant/components/mqtt/binary_sensor.py | 4 ++-- homeassistant/components/mqtt/button.py | 8 ++++++-- homeassistant/components/mqtt/camera.py | 8 ++++++-- homeassistant/components/mqtt/climate.py | 4 ++-- homeassistant/components/mqtt/cover.py | 4 ++-- homeassistant/components/mqtt/device_automation.py | 6 ++++-- homeassistant/components/mqtt/device_tracker.py | 4 ++-- homeassistant/components/mqtt/event.py | 4 ++-- homeassistant/components/mqtt/fan.py | 4 ++-- homeassistant/components/mqtt/humidifier.py | 4 ++-- homeassistant/components/mqtt/image.py | 8 ++++++-- homeassistant/components/mqtt/lawn_mower.py | 4 ++-- homeassistant/components/mqtt/light/__init__.py | 4 ++-- homeassistant/components/mqtt/lock.py | 4 ++-- homeassistant/components/mqtt/mixins.py | 6 +++--- homeassistant/components/mqtt/number.py | 4 ++-- homeassistant/components/mqtt/scene.py | 8 ++++++-- homeassistant/components/mqtt/select.py | 4 ++-- homeassistant/components/mqtt/sensor.py | 4 ++-- homeassistant/components/mqtt/siren.py | 4 ++-- homeassistant/components/mqtt/switch.py | 4 ++-- homeassistant/components/mqtt/tag.py | 4 ++-- homeassistant/components/mqtt/text.py | 4 ++-- homeassistant/components/mqtt/update.py | 4 ++-- homeassistant/components/mqtt/vacuum/__init__.py | 4 ++-- homeassistant/components/mqtt/water_heater.py | 4 ++-- 27 files changed, 73 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 1eb210bf99e..68aca18f249 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -43,7 +43,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -132,7 +132,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT alarm control panel through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttAlarm, diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b8f7c73eede..7ab2e9ebf90 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -41,7 +41,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -76,7 +76,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT binary sensor through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttBinarySensor, diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index a6bc43bece2..f0d8037b60d 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -20,7 +20,11 @@ from .const import ( CONF_QOS, CONF_RETAIN, ) -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -48,7 +52,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT button through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttButton, diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 1a2d4744948..954cddd20f7 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -20,7 +20,11 @@ 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 MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -60,7 +64,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT camera through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttCamera, diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 4437f2a6270..dae768a1359 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -84,7 +84,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -399,7 +399,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT climate through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttClimate, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 367390aefaf..c8da14e67e6 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -47,7 +47,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -219,7 +219,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT cover through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttCover, diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 13d0b9ea530..c0e6f5750fb 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -11,7 +11,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import device_trigger from .config import MQTT_BASE_SCHEMA -from .mixins import async_setup_entry_helper +from .mixins import async_setup_non_entity_entry_helper AUTOMATION_TYPE_TRIGGER = "trigger" AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] @@ -28,7 +28,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT device automation dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_automation, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, "device_automation", setup, DISCOVERY_SCHEMA) + await async_setup_non_entity_entry_helper( + hass, "device_automation", setup, DISCOVERY_SCHEMA + ) async def _async_setup_automation( diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 1216a68fe7b..6e5aeb8f228 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -35,7 +35,7 @@ from .mixins import ( CONF_JSON_ATTRS_TOPIC, MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType @@ -85,7 +85,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttDeviceTracker, diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 39057314508..c9302bf65b1 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -34,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -82,7 +82,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT event through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttEvent, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 783573c8e00..02192676784 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -52,7 +52,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -199,7 +199,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT fan through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttFan, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 1e56ba1649a..77a74b15197 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -54,7 +54,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -191,7 +191,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT humidifier through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttHumidifier, diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index bf4ca584c47..1f90f0fdb3d 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -26,7 +26,11 @@ 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 MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_subscribe_topic @@ -78,7 +82,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT image through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttImage, diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index cd19e9c26ed..924d34bf5c7 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -34,7 +34,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -91,7 +91,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lawn mower through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttLawnMower, diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 15431616658..a5f3f0aca84 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType -from ..mixins import async_mqtt_entry_helper +from ..mixins import async_setup_entity_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, @@ -69,7 +69,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lights through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, None, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index f6177d94410..26b6009426c 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -36,7 +36,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -112,7 +112,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT lock through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttLock, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 2b86d8b3e87..767a012d179 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -305,7 +305,7 @@ async def _async_discover( raise -async def async_setup_entry_helper( +async def async_setup_non_entity_entry_helper( hass: HomeAssistant, domain: str, async_setup: partial[Coroutine[Any, Any, None]], @@ -332,7 +332,7 @@ async def async_setup_entry_helper( ) -async def async_mqtt_entry_helper( +async def async_setup_entity_entry_helper( hass: HomeAssistant, entry: ConfigEntry, entity_class: type[MqttEntity] | None, @@ -342,7 +342,7 @@ async def async_mqtt_entry_helper( platform_schema_modern: vol.Schema, schema_class_mapping: dict[str, type[MqttEntity]] | None = None, ) -> None: - """Set up entity, automation or tag creation dynamically through MQTT discovery.""" + """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) async def async_setup_from_discovery( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 34616df41bc..83eb047519f 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -44,7 +44,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -117,7 +117,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT number through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttNumber, diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 7e41e5e8592..de75f470228 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -16,7 +16,11 @@ from homeassistant.helpers.typing import ConfigType from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN -from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_mqtt_entry_helper +from .mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_setup_entity_entry_helper, +) from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" @@ -42,7 +46,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT scene through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttScene, diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 6c391232072..5d9bc989c25 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -30,7 +30,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -72,7 +72,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT select through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttSelect, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index a4a59de4dfb..93151c51542 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -43,7 +43,7 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -105,7 +105,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT sensor through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttSensor, diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 3ba7df84cc9..cb2ecbafa55 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -51,7 +51,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -121,7 +121,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT siren through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttSiren, diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 7221d02611e..c45e6dd77ab 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -39,7 +39,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage @@ -71,7 +71,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT switch through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttSwitch, diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index e87a9b0da6e..80a717b1f37 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -21,7 +21,7 @@ from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, MqttDiscoveryDeviceUpdate, async_handle_schema_error, - async_setup_entry_helper, + async_setup_non_entity_entry_helper, send_discovery_done, update_device, ) @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N """Set up MQTT tag scanner dynamically through MQTT discovery.""" setup = functools.partial(_async_setup_tag, hass, config_entry=config_entry) - await async_setup_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) + await async_setup_non_entity_entry_helper(hass, TAG, setup, DISCOVERY_SCHEMA) async def _async_setup_tag( diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 3fd0f9a4198..f6aeac3be7c 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -37,7 +37,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import ( @@ -107,7 +107,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT text through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttTextEntity, diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index c9ad17c078c..45424995224 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -35,7 +35,7 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage @@ -91,7 +91,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT update entity through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttUpdate, diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index cbf99073ba5..fabbb9868df 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers.issue_registry import IssueSeverity, async_create_iss from homeassistant.helpers.typing import ConfigType from ..const import DOMAIN -from ..mixins import async_mqtt_entry_helper +from ..mixins import async_setup_entity_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, @@ -110,7 +110,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT vacuum through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, None, diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 99cc35f74a4..0ccd2dbc47d 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -66,7 +66,7 @@ from .const import ( from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, - async_mqtt_entry_helper, + async_setup_entity_entry_helper, write_state_on_attr_change, ) from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage @@ -169,7 +169,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up MQTT water heater device through YAML and through MQTT discovery.""" - await async_mqtt_entry_helper( + await async_setup_entity_entry_helper( hass, config_entry, MqttWaterHeater, From 712c061ac08936997c928aa74328b36ab4a0380b Mon Sep 17 00:00:00 2001 From: Archomeda Date: Fri, 20 Oct 2023 14:00:31 +0200 Subject: [PATCH 607/968] Fix Spotify media position update value (#100044) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/spotify/media_player.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index d05e4282edf..6ef2697ba77 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations from asyncio import run_coroutine_threadsafe -import datetime as dt from datetime import timedelta import logging from typing import Any @@ -27,7 +26,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.dt import utc_from_timestamp +from homeassistant.util.dt import utcnow from . import HomeAssistantSpotifyData from .browse_media import async_browse_media_internal @@ -199,13 +198,6 @@ class SpotifyMediaPlayer(MediaPlayerEntity): return None return self._currently_playing["progress_ms"] / 1000 - @property - def media_position_updated_at(self) -> dt.datetime | None: - """When was the position of the current playing media valid.""" - if not self._currently_playing: - return None - return utc_from_timestamp(self._currently_playing["timestamp"] / 1000) - @property def media_image_url(self) -> str | None: """Return the media image URL.""" @@ -413,6 +405,9 @@ class SpotifyMediaPlayer(MediaPlayerEntity): additional_types=[MediaType.EPISODE] ) self._currently_playing = current or {} + # Record the last updated time, because Spotify's timestamp property is unreliable + # and doesn't actually return the fetch time as is mentioned in the API description + self._attr_media_position_updated_at = utcnow() if current is not None else None context = self._currently_playing.get("context") or {} From fe8fb8928c02d89929e7415c887a8c3d3f02f4eb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 14:49:33 +0200 Subject: [PATCH 608/968] Improve test creating mqtt certificate files (#102380) * Improve test creating mqtt certificate files * Split tests * Cleanup and de-duplicate * Update tests/components/mqtt/test_util.py --- tests/components/mqtt/test_util.py | 87 ++++++++++++++++++++---------- 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index 941072bc224..d1f265770b8 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -3,6 +3,7 @@ from collections.abc import Callable from pathlib import Path from random import getrandbits +import shutil import tempfile from unittest.mock import patch @@ -16,62 +17,94 @@ from tests.common import MockConfigEntry from tests.typing import MqttMockHAClient, MqttMockPahoClient +async def help_create_test_certificate_file( + hass: HomeAssistant, + mock_temp_dir: str, + option: str, + content: bytes = b"old content", +) -> None: + """Help creating a certificate test file.""" + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + + def _create_file() -> None: + if not temp_dir.exists(): + temp_dir.mkdir(0o700) + temp_file = temp_dir / option + with open(temp_file, "wb") as old_file: + old_file.write(content) + old_file.close() + + await hass.async_add_executor_job(_create_file) + + @pytest.mark.parametrize( - ("option", "content", "file_created"), + ("option", "content"), [ - (mqtt.CONF_CERTIFICATE, "auto", False), - (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###", True), - (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###", True), - (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), + (mqtt.CONF_CERTIFICATE, "### CA CERTIFICATE ###"), + (mqtt.CONF_CLIENT_CERT, "### CLIENT CERTIFICATE ###"), + (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###"), ], ) -@pytest.mark.parametrize("temp_dir_prefix", ["create-test"]) +@pytest.mark.parametrize("temp_dir_prefix", ["create-test1"]) async def test_async_create_certificate_temp_files( hass: HomeAssistant, mock_temp_dir: str, option: str, content: str, - file_created: bool, ) -> None: """Test creating and reading and recovery certificate files.""" config = {option: content} - temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir - - # Create old file to be able to assert it is removed with auto option - def _ensure_old_file_exists() -> None: - if not temp_dir.exists(): - temp_dir.mkdir(0o700) - temp_file = temp_dir / option - with open(temp_file, "wb") as old_file: - old_file.write(b"old content") - old_file.close() - - await hass.async_add_executor_job(_ensure_old_file_exists) + # Create old file to be able to assert it is replaced and recovered + await help_create_test_certificate_file(hass, mock_temp_dir, option) await mqtt.util.async_create_certificate_temp_files(hass, config) file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) - assert bool(file_path) is file_created + assert file_path is not None assert ( await hass.async_add_executor_job( - mqtt.util.migrate_certificate_file_to_content, file_path or content + mqtt.util.migrate_certificate_file_to_content, file_path ) == content ) - # Make sure certificate temp files are recovered - await hass.async_add_executor_job(_ensure_old_file_exists) + # Make sure old files are removed to test certificate and dir creation + def _remove_old_files() -> None: + temp_dir = Path(tempfile.gettempdir()) / mock_temp_dir + shutil.rmtree(temp_dir) + await hass.async_add_executor_job(_remove_old_files) + + # Test a new dir and file is created correctly await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path2 = await hass.async_add_executor_job(mqtt.util.get_file_path, option) - assert bool(file_path2) is file_created + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, option) + assert file_path is not None assert ( await hass.async_add_executor_job( - mqtt.util.migrate_certificate_file_to_content, file_path2 or content + mqtt.util.migrate_certificate_file_to_content, file_path ) == content ) - assert file_path == file_path2 + +@pytest.mark.parametrize("temp_dir_prefix", ["create-test2"]) +async def test_certificate_temp_files_with_auto_mode( + hass: HomeAssistant, + mock_temp_dir: str, +) -> None: + """Test creating and reading and recovery certificate files with auto mode.""" + config = {mqtt.CONF_CERTIFICATE: "auto"} + + # Create old file to be able to assert it is removed with auto option + await help_create_test_certificate_file(hass, mock_temp_dir, mqtt.CONF_CERTIFICATE) + await mqtt.util.async_create_certificate_temp_files(hass, config) + file_path = await hass.async_add_executor_job(mqtt.util.get_file_path, "auto") + assert file_path is None + assert ( + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, "auto" + ) + == "auto" + ) async def test_reading_non_exitisting_certificate_file() -> None: From 6f83374f3e15a7112f6876786e6d6052a1a9bdbb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 20 Oct 2023 14:52:32 +0200 Subject: [PATCH 609/968] Bump pydiscovergy to 2.0.5 (#102354) --- homeassistant/components/discovergy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/discovergy/manifest.json b/homeassistant/components/discovergy/manifest.json index 7cfa8c4d1ee..f70a531215e 100644 --- a/homeassistant/components/discovergy/manifest.json +++ b/homeassistant/components/discovergy/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/discovergy", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pydiscovergy==2.0.4"] + "requirements": ["pydiscovergy==2.0.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 95a82d727d6..b476083b543 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1664,7 +1664,7 @@ pydelijn==1.1.0 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.4 +pydiscovergy==2.0.5 # homeassistant.components.doods pydoods==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 287877810af..840bc509aba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1255,7 +1255,7 @@ pydeconz==113 pydexcom==0.2.3 # homeassistant.components.discovergy -pydiscovergy==2.0.4 +pydiscovergy==2.0.5 # homeassistant.components.hydrawise pydrawise==2023.10.0 From 16e3ed47e7ab76cd6d79e06effbfd18d0d02da56 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 20 Oct 2023 14:54:51 +0200 Subject: [PATCH 610/968] Bump vehicle to 2.0.0 (#102379) --- homeassistant/components/rdw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rdw/manifest.json b/homeassistant/components/rdw/manifest.json index 5df34652f2b..bc8d3be8451 100644 --- a/homeassistant/components/rdw/manifest.json +++ b/homeassistant/components/rdw/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["vehicle==1.0.1"] + "requirements": ["vehicle==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b476083b543..53a057345dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2656,7 +2656,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.3.0 # homeassistant.components.rdw -vehicle==1.0.1 +vehicle==2.0.0 # homeassistant.components.velbus velbus-aio==2023.10.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 840bc509aba..852b94d3b67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1974,7 +1974,7 @@ uvcclient==0.11.0 vallox-websocket-api==3.3.0 # homeassistant.components.rdw -vehicle==1.0.1 +vehicle==2.0.0 # homeassistant.components.velbus velbus-aio==2023.10.1 From c042863486f5e39efffb30b86ea3b9489459cd39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 20 Oct 2023 15:10:40 +0200 Subject: [PATCH 611/968] Update aioairzone-cloud to v0.2.5 (#102382) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index 418b6538a42..e8f5d8d86bb 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.4"] + "requirements": ["aioairzone-cloud==0.2.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 53a057345dd..d5987e2a159 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.4 +aioairzone-cloud==0.2.5 # homeassistant.components.airzone aioairzone==0.6.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 852b94d3b67..ebc54fa20dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.4 +aioairzone-cloud==0.2.5 # homeassistant.components.airzone aioairzone==0.6.8 From 8202071683112a95c27843c6eb3045ac89eeb124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 20 Oct 2023 15:11:48 +0200 Subject: [PATCH 612/968] Update aioairzone to v0.6.9 (#102383) --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index c0b24b2cc3e..e9485f1b9d0 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone", "iot_class": "local_polling", "loggers": ["aioairzone"], - "requirements": ["aioairzone==0.6.8"] + "requirements": ["aioairzone==0.6.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index d5987e2a159..e297432899c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -195,7 +195,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.5 # homeassistant.components.airzone -aioairzone==0.6.8 +aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebc54fa20dc..053d7e16fb5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -176,7 +176,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.5 # homeassistant.components.airzone -aioairzone==0.6.8 +aioairzone==0.6.9 # homeassistant.components.ambient_station aioambient==2023.04.0 From d7e195ba40b47821d294cbbcf7b0f00e73065f3b Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 20 Oct 2023 15:14:31 +0200 Subject: [PATCH 613/968] Use snapshots in calendar tests (#102299) --- .../calendar/snapshots/test_init.ambr | 31 +++++++++++++++++++ tests/components/calendar/test_init.py | 24 +++++++------- 2 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 tests/components/calendar/snapshots/test_init.ambr diff --git a/tests/components/calendar/snapshots/test_init.ambr b/tests/components/calendar/snapshots/test_init.ambr new file mode 100644 index 00000000000..7d48228193a --- /dev/null +++ b/tests/components/calendar/snapshots/test_init.ambr @@ -0,0 +1,31 @@ +# serializer version: 1 +# name: test_list_events_service_duration[calendar.calendar_1-00:15:00] + dict({ + 'events': list([ + ]), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_1-01:00:00] + dict({ + 'events': list([ + dict({ + 'description': 'Future Description', + 'end': '2023-10-19T08:20:05-07:00', + 'location': 'Future Location', + 'start': '2023-10-19T07:20:05-07:00', + 'summary': 'Future Event', + }), + ]), + }) +# --- +# name: test_list_events_service_duration[calendar.calendar_2-00:15:00] + dict({ + 'events': list([ + dict({ + 'end': '2023-10-19T07:20:05-07:00', + 'start': '2023-10-19T06:20:05-07:00', + 'summary': 'Current Event', + }), + ]), + }) +# --- diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index e0fbbf0cdeb..ad83d039d73 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -8,6 +8,7 @@ from unittest.mock import patch from freezegun import freeze_time import pytest +from syrupy.assertion import SnapshotAssertion import voluptuous as vol from homeassistant.bootstrap import async_setup_component @@ -368,7 +369,7 @@ async def test_create_event_service_invalid_params( date_fields: dict[str, Any], expected_error: type[Exception], error_match: str | None, -): +) -> None: """Test creating an event using the create_event service.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) @@ -397,7 +398,10 @@ async def test_create_event_service_invalid_params( ], ) async def test_list_events_service( - hass: HomeAssistant, set_time_zone: None, start_time: str, end_time: str + hass: HomeAssistant, + set_time_zone: None, + start_time: str, + end_time: str, ) -> None: """Test listing events from the service call using exlplicit start and end time. @@ -433,21 +437,22 @@ async def test_list_events_service( @pytest.mark.parametrize( - ("entity", "duration", "expected_events"), + ("entity", "duration"), [ # Calendar 1 has an hour long event starting in 30 minutes. No events in the # next 15 minutes, but it shows up an hour from now. - ("calendar.calendar_1", "00:15:00", []), - ("calendar.calendar_1", "01:00:00", ["Future Event"]), + ("calendar.calendar_1", "00:15:00"), + ("calendar.calendar_1", "01:00:00"), # Calendar 2 has a active event right now - ("calendar.calendar_2", "00:15:00", ["Current Event"]), + ("calendar.calendar_2", "00:15:00"), ], ) +@pytest.mark.freeze_time("2023-10-19 13:50:05") async def test_list_events_service_duration( hass: HomeAssistant, entity: str, duration: str, - expected_events: list[str], + snapshot: SnapshotAssertion, ) -> None: """Test listing events using a time duration.""" await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) @@ -463,10 +468,7 @@ async def test_list_events_service_duration( blocking=True, return_response=True, ) - assert response - assert "events" in response - events = response["events"] - assert [event["summary"] for event in events] == expected_events + assert response == snapshot async def test_list_events_positive_duration(hass: HomeAssistant) -> None: From 20a58d23147567a48788bc75078861aa99b18b02 Mon Sep 17 00:00:00 2001 From: Sjors <40437966+Sjorsa@users.noreply.github.com> Date: Fri, 20 Oct 2023 15:15:13 +0200 Subject: [PATCH 614/968] Fix typo in fastdotcom strings (#102384) --- homeassistant/components/fastdotcom/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fastdotcom/strings.json b/homeassistant/components/fastdotcom/strings.json index b1e03681c96..705eada9387 100644 --- a/homeassistant/components/fastdotcom/strings.json +++ b/homeassistant/components/fastdotcom/strings.json @@ -2,7 +2,7 @@ "services": { "speedtest": { "name": "Speed test", - "description": "Immediately executs a speed test with Fast.com." + "description": "Immediately executes a speed test with Fast.com." } } } From 485c52568d545a0ccb4eac17fd3d06ba723c30a4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 15:16:45 +0200 Subject: [PATCH 615/968] Fix error handling on subscribe when mqtt is not initialized (#101832) --- homeassistant/components/mqtt/client.py | 8 +++++++- tests/components/mqtt/test_init.py | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 02f3edd155a..2e4d49b4cd9 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -176,7 +176,13 @@ async def async_subscribe( raise HomeAssistantError( f"Cannot subscribe to topic '{topic}', MQTT is not enabled" ) - mqtt_data = get_mqtt_data(hass) + try: + mqtt_data = get_mqtt_data(hass) + except KeyError as ex: + raise HomeAssistantError( + f"Cannot subscribe to topic '{topic}', " + "make sure MQTT is set up correctly" + ) from ex async_remove = await mqtt_data.client.async_subscribe( topic, catch_log_exception( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index dc81e3d82b9..b071252ea64 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -959,6 +959,17 @@ async def test_subscribe_topic( unsub() +async def test_subscribe_topic_not_initialize( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test the subscription of a topic when MQTT was not initialized.""" + with pytest.raises( + HomeAssistantError, match=r".*make sure MQTT is set up correctly" + ): + 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( From a187164bf8f614111555b6ad225f2628a654119a Mon Sep 17 00:00:00 2001 From: Marco4223 Date: Fri, 20 Oct 2023 15:18:10 +0200 Subject: [PATCH 616/968] Get all playlist items from sonos devices (#100924) --- homeassistant/components/sonos/media_player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 08f2b08f4df..49caafcc774 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -629,7 +629,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): soco.play_uri(item.get_uri()) return try: - playlists = soco.get_sonos_playlists() + playlists = soco.get_sonos_playlists(complete_result=True) playlist = next(p for p in playlists if p.title == media_id) except StopIteration: _LOGGER.error('Could not find a Sonos playlist named "%s"', media_id) From da653c36fd554e72f4d380db105a7139d40514ae Mon Sep 17 00:00:00 2001 From: dupondje Date: Fri, 20 Oct 2023 15:59:34 +0200 Subject: [PATCH 617/968] Add peak usage sensors to dsmr (#102227) --- homeassistant/components/dsmr/sensor.py | 16 +++++++++++ homeassistant/components/dsmr/strings.json | 6 +++++ tests/components/dsmr/test_sensor.py | 31 +++++++++++++++++++++- 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e271aac4ee5..99af30b8111 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -356,6 +356,22 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + DSMRSensorEntityDescription( + key="belgium_current_average_demand", + translation_key="current_average_demand", + obis_reference=obis_references.BELGIUM_CURRENT_AVERAGE_DEMAND, + dsmr_versions={"5B"}, + force_update=True, + device_class=SensorDeviceClass.POWER, + ), + DSMRSensorEntityDescription( + key="belgium_maximum_demand_current_month", + translation_key="maximum_demand_current_month", + obis_reference=obis_references.BELGIUM_MAXIMUM_DEMAND_MONTH, + dsmr_versions={"5B"}, + force_update=True, + device_class=SensorDeviceClass.POWER, + ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", translation_key="gas_meter_reading", diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 7dc44e47a98..5f0568e2905 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -76,6 +76,12 @@ "gas_meter_reading": { "name": "Gas consumption" }, + "current_average_demand": { + "name": "Current average demand" + }, + "maximum_demand_current_month": { + "name": "Maximum demand current month" + }, "instantaneous_active_power_l1_negative": { "name": "Power production phase L1" }, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index d734f0a93d5..9c8c4e6fc70 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -480,7 +480,11 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = dsmr_connection_fixture - from dsmr_parser.obis_references import ELECTRICITY_ACTIVE_TARIFF + from dsmr_parser.obis_references import ( + BELGIUM_CURRENT_AVERAGE_DEMAND, + BELGIUM_MAXIMUM_DEMAND_MONTH, + ELECTRICITY_ACTIVE_TARIFF, + ) from dsmr_parser.objects import CosemObject, MBusObject entry_data = { @@ -503,6 +507,17 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No {"value": Decimal(745.695), "unit": "m3"}, ], ), + BELGIUM_CURRENT_AVERAGE_DEMAND: CosemObject( + BELGIUM_CURRENT_AVERAGE_DEMAND, + [{"value": Decimal(1.75), "unit": "kW"}], + ), + BELGIUM_MAXIMUM_DEMAND_MONTH: MBusObject( + BELGIUM_MAXIMUM_DEMAND_MONTH, + [ + {"value": datetime.datetime.fromtimestamp(1551642218)}, + {"value": Decimal(4.11), "unit": "kW"}, + ], + ), ELECTRICITY_ACTIVE_TARIFF: CosemObject( ELECTRICITY_ACTIVE_TARIFF, [{"value": "0001", "unit": ""}] ), @@ -534,6 +549,20 @@ async def test_belgian_meter(hass: HomeAssistant, dsmr_connection_fixture) -> No assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + # check current average demand is parsed correctly + avg_demand = hass.states.get("sensor.electricity_meter_current_average_demand") + assert avg_demand.state == "1.75" + assert avg_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT + assert avg_demand.attributes.get(ATTR_STATE_CLASS) is None + + # check max average demand is parsed correctly + max_demand = hass.states.get( + "sensor.electricity_meter_maximum_demand_current_month" + ) + assert max_demand.state == "4.11" + assert max_demand.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.KILO_WATT + assert max_demand.attributes.get(ATTR_STATE_CLASS) is None + # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" From 5e483c55731a2244117c7f8923340bfe8542bb8e Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Fri, 20 Oct 2023 16:02:56 +0200 Subject: [PATCH 618/968] Create a binary sensor for each Duotecno virtual unit (#102347) --- homeassistant/components/duotecno/binary_sensor.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/duotecno/binary_sensor.py b/homeassistant/components/duotecno/binary_sensor.py index a1638ce4055..5867e2d634e 100644 --- a/homeassistant/components/duotecno/binary_sensor.py +++ b/homeassistant/components/duotecno/binary_sensor.py @@ -1,6 +1,6 @@ """Support for Duotecno binary sensors.""" -from duotecno.unit import ControlUnit +from duotecno.unit import ControlUnit, VirtualUnit from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry @@ -19,14 +19,15 @@ async def async_setup_entry( """Set up Duotecno binary sensor on config_entry.""" cntrl = hass.data[DOMAIN][entry.entry_id] async_add_entities( - DuotecnoBinarySensor(channel) for channel in cntrl.get_units("ControlUnit") + DuotecnoBinarySensor(channel) + for channel in cntrl.get_units(["ControlUnit", "VirtualUnit"]) ) class DuotecnoBinarySensor(DuotecnoEntity, BinarySensorEntity): """Representation of a DuotecnoBinarySensor.""" - _unit: ControlUnit + _unit: ControlUnit | VirtualUnit @property def is_on(self) -> bool: From cc0491e85ded4de30154d462104987da0afe62ba Mon Sep 17 00:00:00 2001 From: Kostas Chatzikokolakis Date: Fri, 20 Oct 2023 17:23:02 +0300 Subject: [PATCH 619/968] Use action response in intent_script speech template (#96256) --- .../components/intent_script/__init__.py | 10 ++++-- tests/components/intent_script/test_init.py | 32 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index b3e7c44f086..d5bec0573b8 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import TypedDict +from typing import Any, TypedDict import voluptuous as vol @@ -144,7 +144,7 @@ class ScriptIntentHandler(intent.IntentHandler): card: _IntentCardData | None = self.config.get(CONF_CARD) action: script.Script | None = self.config.get(CONF_ACTION) is_async_action: bool = self.config[CONF_ASYNC_ACTION] - slots: dict[str, str] = { + slots: dict[str, Any] = { key: value["value"] for key, value in intent_obj.slots.items() } @@ -164,7 +164,11 @@ class ScriptIntentHandler(intent.IntentHandler): action.async_run(slots, intent_obj.context) ) else: - await action.async_run(slots, intent_obj.context) + action_res = await action.async_run(slots, intent_obj.context) + + # if the action returns a response, make it available to the speech/reprompt templates below + if action_res and action_res.service_response is not None: + slots["action_response"] = action_res.service_response response = intent_obj.create_response() diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index a68b2a9be24..fe694607def 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -95,6 +95,38 @@ async def test_intent_script_wait_response(hass: HomeAssistant) -> None: assert response.card["simple"]["content"] == "Content for Paulus" +async def test_intent_script_service_response(hass: HomeAssistant) -> None: + """Test intent scripts work.""" + calls = async_mock_service( + hass, "test", "service", response={"some_key": "some value"} + ) + + await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "HelloWorldServiceResponse": { + "action": [ + {"service": "test.service", "response_variable": "result"}, + {"stop": "", "response_variable": "result"}, + ], + "speech": { + "text": "The service returned {{ action_response.some_key }}" + }, + } + } + }, + ) + + response = await intent.async_handle(hass, "test", "HelloWorldServiceResponse") + + assert len(calls) == 1 + assert calls[0].return_response + + assert response.speech["plain"]["speech"] == "The service returned some value" + + async def test_intent_script_falsy_reprompt(hass: HomeAssistant) -> None: """Test intent scripts work.""" calls = async_mock_service(hass, "test", "service") From 50c7587ab875a1b1d33277f88332a6eecc128254 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 04:49:18 -1000 Subject: [PATCH 620/968] Bump ulid-transform to 0.9.0 (#102272) --- 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 8377bea2e8e..4dab9a1828e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -48,7 +48,7 @@ requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.22 typing-extensions>=4.8.0,<5.0 -ulid-transform==0.8.1 +ulid-transform==0.9.0 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtc-noise-gain==1.2.3 diff --git a/pyproject.toml b/pyproject.toml index 98ebc5e084c..ee3860da30b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dependencies = [ "PyYAML==6.0.1", "requests==2.31.0", "typing-extensions>=4.8.0,<5.0", - "ulid-transform==0.8.1", + "ulid-transform==0.9.0", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", "yarl==1.9.2", diff --git a/requirements.txt b/requirements.txt index b4f97461bbf..df08234d4db 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 typing-extensions>=4.8.0,<5.0 -ulid-transform==0.8.1 +ulid-transform==0.9.0 voluptuous==0.13.1 voluptuous-serialize==2.6.0 yarl==1.9.2 From 3cfcffc6f21f46bf3d0b02f76ebbfe586b200770 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 04:49:40 -1000 Subject: [PATCH 621/968] Bump fnv-hash-fast to 0.5.0 (#102271) --- homeassistant/components/homekit/manifest.json | 2 +- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 563654f1dc9..4f6cc24edc8 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -10,7 +10,7 @@ "loggers": ["pyhap"], "requirements": [ "HAP-python==4.9.0", - "fnv-hash-fast==0.4.1", + "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" ], diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 4557e885570..f0e91071ea0 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -8,7 +8,7 @@ "quality_scale": "internal", "requirements": [ "SQLAlchemy==2.0.22", - "fnv-hash-fast==0.4.1", + "fnv-hash-fast==0.5.0", "psutil-home-assistant==0.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4dab9a1828e..1024239217a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.4 dbus-fast==2.12.0 -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 ha-av==10.1.1 hass-nabucasa==0.73.0 hassil==1.2.5 diff --git a/requirements_all.txt b/requirements_all.txt index e297432899c..440591e76b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -823,7 +823,7 @@ flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 # homeassistant.components.foobot foobot_async==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 053d7e16fb5..fc86cb6855d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -654,7 +654,7 @@ flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder -fnv-hash-fast==0.4.1 +fnv-hash-fast==0.5.0 # homeassistant.components.foobot foobot_async==1.0.0 From c7affa75d4bbf75bd2a8c1f98113211912857e2b Mon Sep 17 00:00:00 2001 From: kpine Date: Fri, 20 Oct 2023 10:57:00 -0700 Subject: [PATCH 622/968] Fix temperature setting for multi-setpoint z-wave device (#102395) * Fix temperature setting for multi-setpoint z-wave device * Add missing fixture file * Apply suggestions from code review --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/climate.py | 8 +- tests/components/zwave_js/conftest.py | 14 + .../climate_intermatic_pe653_state.json | 4508 +++++++++++++++++ tests/components/zwave_js/test_climate.py | 193 + 4 files changed, 4720 insertions(+), 3 deletions(-) create mode 100644 tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index d511a030fb1..28084eecfa6 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,9 +259,11 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat(valve) with no support for setting a mode - # is considered heating-only - return [ThermostatSetpointType.HEATING] + # Thermostat with no support for setting a mode is just a setpoint + if self.info.primary_value.property_key is None: + return [] + return [ThermostatSetpointType(int(self.info.primary_value.property_key))] + return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index bbc836488c2..b9feeab1f2f 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,6 +662,12 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) +@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") +def climate_intermatic_pe653_state_fixture(): + """Load Intermatic PE653 Pool Control node state fixture data.""" + return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) + + # model fixtures @@ -1290,3 +1296,11 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): node = Node(client, copy.deepcopy(logic_group_zdb5100_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="climate_intermatic_pe653") +def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): + """Mock an Intermatic PE653 node.""" + node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json new file mode 100644 index 00000000000..a5e86b9c013 --- /dev/null +++ b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json @@ -0,0 +1,4508 @@ +{ + "nodeId": 19, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 5, + "productId": 1619, + "productType": 20549, + "firmwareVersion": "3.9", + "deviceConfig": { + "filename": "/data/db/devices/0x0005/pe653.json", + "isEmbedded": true, + "manufacturer": "Intermatic", + "manufacturerId": 5, + "label": "PE653", + "description": "Pool Control", + "devices": [ + { + "productType": 20549, + "productId": 1619 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "addCCs": {}, + "overrideQueries": { + "overrides": {} + } + } + }, + "label": "PE653", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 39, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 19, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 145, + "name": "Manufacturer Proprietary", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 129, + "name": "Clock", + "version": 1, + "isSecure": false + }, + { + "id": 96, + "name": "Multi Channel", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": false + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 48, + "name": "Binary Sensor", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 145, + "name": "Manufacturer Proprietary", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 1, + "isSecure": false + }, + { + "id": 67, + "name": "Thermostat Setpoint", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 48, + "name": "Binary Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 129, + "name": "Clock", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 2, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 3, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 4, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 6, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 8, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 9, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 10, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 11, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 12, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 13, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 14, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 15, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 16, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 17, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 18, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 19, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 20, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 21, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 22, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 23, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 49, + "name": "Multilevel Sensor", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 24, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 25, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 26, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 27, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 28, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 29, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 30, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 31, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 32, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 33, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 34, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 35, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 36, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 37, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 38, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 19, + "index": 39, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 7, + "propertyName": "setpoint", + "propertyKeyName": "Furnace", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Furnace)", + "ccSpecific": { + "setpointType": 7 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 60 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 2, + "propertyName": "Installed Pump Type", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Installed Pump Type", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "One Speed", + "1": "Two Speed" + }, + "valueSize": 2, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Installed Pump Type" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 1, + "propertyName": "Booster (Cleaner) Pump Installed", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Booster (Cleaner) Pump Installed", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Booster (Cleaner) Pump Installed" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 65280, + "propertyName": "Booster (Cleaner) Pump Operation Mode", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", + "label": "Booster (Cleaner) Pump Operation Mode", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "Disable", + "2": "Circuit 1", + "3": "VSP Speed 1", + "4": "VSP Speed 2", + "5": "VSP Speed 3", + "6": "VSP Speed 4" + }, + "valueSize": 2, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Booster (Cleaner) Pump Operation Mode", + "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 65280, + "propertyName": "Heater Cooldown Period", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Heater Cooldown Period", + "default": -1, + "min": -1, + "max": 15, + "states": { + "0": "Heater installed with no cooldown", + "-1": "No heater installed" + }, + "unit": "minutes", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Heater Cooldown Period" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyKey": 1, + "propertyName": "Heater Safety Setting", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Prevent the heater from turning on while the pump is off.", + "label": "Heater Safety Setting", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Heater Safety Setting", + "info": "Prevent the heater from turning on while the pump is off." + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 4278190080, + "propertyName": "Water Temperature Offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Water Temperature Offset", + "default": 0, + "min": -5, + "max": 5, + "unit": "°F", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Water Temperature Offset" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 16711680, + "propertyName": "Air/Freeze Temperature Offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Air/Freeze Temperature Offset", + "default": 0, + "min": -5, + "max": 5, + "unit": "°F", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Air/Freeze Temperature Offset" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyKey": 65280, + "propertyName": "Solar Temperature Offset", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Solar Temperature Offset", + "default": 0, + "min": -5, + "max": 5, + "unit": "°F", + "valueSize": 4, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Solar Temperature Offset" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Pool/Spa Configuration", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Pool/Spa Configuration", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Pool", + "1": "Spa", + "2": "Both" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Pool/Spa Configuration" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "Spa Mode Pump Speed", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires pool/spa configuration.", + "label": "Spa Mode Pump Speed", + "default": 1, + "min": 1, + "max": 6, + "states": { + "1": "Disabled", + "2": "Circuit 1", + "3": "VSP Speed 1", + "4": "VSP Speed 2", + "5": "VSP Speed 3", + "6": "VSP Speed 4" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Spa Mode Pump Speed", + "info": "Requires pool/spa configuration." + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Variable Speed Pump - Speed 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires connected variable speed pump.", + "label": "Variable Speed Pump - Speed 1", + "default": 750, + "min": 400, + "max": 3450, + "unit": "RPM", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump - Speed 1", + "info": "Requires connected variable speed pump." + }, + "value": 1400 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Variable Speed Pump - Speed 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires connected variable speed pump.", + "label": "Variable Speed Pump - Speed 2", + "default": 1500, + "min": 400, + "max": 3450, + "unit": "RPM", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump - Speed 2", + "info": "Requires connected variable speed pump." + }, + "value": 1700 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyName": "Variable Speed Pump - Speed 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires connected variable speed pump.", + "label": "Variable Speed Pump - Speed 3", + "default": 2350, + "min": 400, + "max": 3450, + "unit": "RPM", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump - Speed 3", + "info": "Requires connected variable speed pump." + }, + "value": 2500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Variable Speed Pump - Speed 4", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires connected variable speed pump.", + "label": "Variable Speed Pump - Speed 4", + "default": 3110, + "min": 400, + "max": 3450, + "unit": "RPM", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump - Speed 4", + "info": "Requires connected variable speed pump." + }, + "value": 2500 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 49, + "propertyName": "Variable Speed Pump - Max Speed", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires connected variable speed pump.", + "label": "Variable Speed Pump - Max Speed", + "default": 3450, + "min": 400, + "max": 3450, + "unit": "RPM", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump - Max Speed", + "info": "Requires connected variable speed pump." + }, + "value": 3000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 4278190080, + "propertyName": "Freeze Protection: Temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Temperature", + "default": 0, + "min": 0, + "max": 44, + "states": { + "0": "Disabled", + "40": "40 °F", + "41": "41 °F", + "42": "42 °F", + "43": "43 °F", + "44": "44 °F" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Temperature" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 65536, + "propertyName": "Freeze Protection: Turn On Circuit 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Turn On Circuit 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Circuit 1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 131072, + "propertyName": "Freeze Protection: Turn On Circuit 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Turn On Circuit 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Circuit 2" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 262144, + "propertyName": "Freeze Protection: Turn On Circuit 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Turn On Circuit 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Circuit 3" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 524288, + "propertyName": "Freeze Protection: Turn On Circuit 4", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Turn On Circuit 4", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Circuit 4" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 1048576, + "propertyName": "Freeze Protection: Turn On Circuit 5", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Freeze Protection: Turn On Circuit 5", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Circuit 5" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 65280, + "propertyName": "Freeze Protection: Turn On VSP Speed", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires variable speed pump and connected air/freeze sensor.", + "label": "Freeze Protection: Turn On VSP Speed", + "default": 0, + "min": 0, + "max": 5, + "states": { + "0": "None", + "2": "VSP Speed 1", + "3": "VSP Speed 2", + "4": "VSP Speed 3", + "5": "VSP Speed 4" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On VSP Speed", + "info": "Requires variable speed pump and connected air/freeze sensor." + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 128, + "propertyName": "Freeze Protection: Turn On Heater", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires heater and connected air/freeze sensor.", + "label": "Freeze Protection: Turn On Heater", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Freeze Protection: Turn On Heater", + "info": "Requires heater and connected air/freeze sensor." + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 50, + "propertyKey": 127, + "propertyName": "Freeze Protection: Pool/Spa Cycle Time", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Requires pool/spa configuration and connected air/freeze sensor.", + "label": "Freeze Protection: Pool/Spa Cycle Time", + "default": 0, + "min": 0, + "max": 30, + "unit": "minutes", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Freeze Protection: Pool/Spa Cycle Time", + "info": "Requires pool/spa configuration and connected air/freeze sensor." + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Circuit 1 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", + "label": "Circuit 1 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 1 Schedule 1", + "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." + }, + "value": 1979884035 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Circuit 1 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 1 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 1 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Circuit 1 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 1 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 1 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Circuit 2 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 2 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 2 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Circuit 2 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 2 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 2 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Circuit 2 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 2 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 2 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Circuit 3 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 3 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 3 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Circuit 3 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 3 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 3 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Circuit 3 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 3 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 3 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Circuit 4 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 4 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 4 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyName": "Circuit 4 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 4 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 4 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Circuit 4 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 4 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 4 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Circuit 5 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 5 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 5 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Circuit 5 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 5 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 5 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Circuit 5 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Circuit 5 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Circuit 5 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Pool/Spa Mode Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Pool/Spa Mode Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Pool/Spa Mode Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Pool/Spa Mode Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Pool/Spa Mode Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Pool/Spa Mode Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Pool/Spa Mode Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Pool/Spa Mode Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Pool/Spa Mode Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Variable Speed Pump Speed 1 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 1 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 1 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyName": "Variable Speed Pump Speed 1 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 1 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 1 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyName": "Variable Speed Pump Speed 1 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 1 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 1 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyName": "Variable Speed Pump Speed 2 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 2 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 2 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 1476575235 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Variable Speed Pump Speed 2 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 2 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 2 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyName": "Variable Speed Pump Speed 2 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 2 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 2 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyName": "Variable Speed Pump Speed 3 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 3 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 3 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Variable Speed Pump Speed 3 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 3 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 3 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Variable Speed Pump Speed 3 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 3 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 3 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 45, + "propertyName": "Variable Speed Pump Speed 4 Schedule 1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 4 Schedule 1", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 4 Schedule 1", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyName": "Variable Speed Pump Speed 4 Schedule 2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 4 Schedule 2", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 4 Schedule 2", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyName": "Variable Speed Pump Speed 4 Schedule 3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Refer to parameter 4 for usage.", + "label": "Variable Speed Pump Speed 4 Schedule 3", + "default": 4294967295, + "min": 0, + "max": 4294967295, + "states": { + "4294967295": "Disabled" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Variable Speed Pump Speed 4 Schedule 3", + "info": "Refer to parameter 4 for usage." + }, + "value": 4294967295 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 20549 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1619 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "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": 6 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "2.78" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["3.9"] + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 1, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 48, + "commandClassName": "Binary Sensor", + "property": "Any", + "propertyName": "Any", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Sensor state (Any)", + "ccSpecific": { + "sensorType": 255 + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 81, + "nodeId": 19 + }, + { + "endpoint": 1, + "commandClass": 67, + "commandClassName": "Thermostat Setpoint", + "property": "setpoint", + "propertyKey": 1, + "propertyName": "setpoint", + "propertyKeyName": "Heating", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Setpoint (Heating)", + "ccSpecific": { + "setpointType": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 39 + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 2, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 84, + "nodeId": 19 + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 3, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 86, + "nodeId": 19 + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 4, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 80, + "nodeId": 19 + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 5, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 49, + "commandClassName": "Multilevel Sensor", + "property": "Air temperature", + "propertyName": "Air temperature", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Air temperature", + "ccSpecific": { + "sensorType": 1, + "scale": 1 + }, + "unit": "°F", + "stateful": true, + "secret": false + }, + "value": 83, + "nodeId": 19 + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 6, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 7, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 8, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 9, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 10, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 11, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 12, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 13, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 14, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 14, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 15, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 15, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 16, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 16, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 17, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 17, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 18, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 18, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 19, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 19, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 20, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 20, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 21, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 21, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 22, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 22, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 23, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 23, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 24, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 24, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 25, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 25, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 26, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 26, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 27, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 27, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 28, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 28, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 29, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 29, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 30, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 30, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 31, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 31, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 32, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 32, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 33, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 33, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 34, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 34, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 35, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 35, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 36, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 36, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 37, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 37, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 38, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 38, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 39, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": true + }, + { + "endpoint": 39, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": false, + "maxDataRate": 40000, + "supportedDataRates": [40000], + "protocolVersion": 2, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 16, + "label": "Binary Switch" + }, + "specific": { + "key": 1, + "label": "Binary Power Switch" + }, + "mandatorySupportedCCs": [32, 37, 39], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index e9040dfd397..cdc1e9959a7 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,3 +792,196 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) + + +async def test_multi_setpoint_thermostat( + hass: HomeAssistant, client, climate_intermatic_pe653, integration +) -> None: + """Test a thermostat with multiple setpoints.""" + node = climate_intermatic_pe653 + + heating_entity_id = "climate.pool_control_2" + heating = hass.states.get(heating_entity_id) + assert heating + assert heating.state == HVACMode.HEAT + assert heating.attributes[ATTR_TEMPERATURE] == 3.9 + assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] + assert ( + heating.attributes[ATTR_SUPPORTED_FEATURES] + == ClimateEntityFeature.TARGET_TEMPERATURE + ) + + furnace_entity_id = "climate.pool_control" + furnace = hass.states.get(furnace_entity_id) + assert furnace + assert furnace.state == HVACMode.HEAT + assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 + assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] + assert ( + furnace.attributes[ATTR_SUPPORTED_FEATURES] + == ClimateEntityFeature.TARGET_TEMPERATURE + ) + + client.async_send_command_no_wait.reset_mock() + + # Test setting temperature of heating setpoint + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: heating_entity_id, + ATTR_TEMPERATURE: 20.0, + }, + blocking=True, + ) + + # Test setting temperature of furnace setpoint + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: furnace_entity_id, + ATTR_TEMPERATURE: 2.0, + }, + blocking=True, + ) + + # Test setting illegal mode raises an error + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: heating_entity_id, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: furnace_entity_id, + ATTR_HVAC_MODE: HVACMode.COOL, + }, + blocking=True, + ) + + # this is a no-op since there's no mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: heating_entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + # this is a no-op since there's no mode + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: furnace_entity_id, + ATTR_HVAC_MODE: HVACMode.HEAT, + }, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 2 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 19 + assert args["valueId"] == { + "endpoint": 1, + "commandClass": 67, + "property": "setpoint", + "propertyKey": 1, + } + assert args["value"] == 68.0 + + args = client.async_send_command.call_args_list[1][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 19 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 67, + "property": "setpoint", + "propertyKey": 7, + } + assert args["value"] == 35.6 + + client.async_send_command.reset_mock() + + # Test heating setpoint value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 19, + "args": { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 1, + "property": "setpoint", + "propertyKey": 1, + "propertyKeyName": "Heating", + "propertyName": "setpoint", + "newValue": 23, + "prevValue": 21.5, + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(heating_entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == -5 + + # furnace not changed + state = hass.states.get(furnace_entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 15.6 + + client.async_send_command.reset_mock() + + # Test furnace setpoint value update from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 19, + "args": { + "commandClassName": "Thermostat Setpoint", + "commandClass": 67, + "endpoint": 0, + "property": "setpoint", + "propertyKey": 7, + "propertyKeyName": "Furnace", + "propertyName": "setpoint", + "newValue": 68, + "prevValue": 21.5, + }, + }, + ) + node.receive_event(event) + + # heating not changed + state = hass.states.get(heating_entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == -5 + + state = hass.states.get(furnace_entity_id) + assert state + assert state.state == HVACMode.HEAT + assert state.attributes[ATTR_TEMPERATURE] == 20 + + client.async_send_command.reset_mock() From 0bd416e53d6c517db9971019ec13340a6596e393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 08:00:55 -1000 Subject: [PATCH 623/968] Bump aioesphomeapi to 18.0.7 (#102399) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index a024abfe875..8db47d83cad 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.6", + "aioesphomeapi==18.0.7", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 440591e76b3..4bbe04449c5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.6 +aioesphomeapi==18.0.7 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc86cb6855d..d6fb4726c11 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.6 +aioesphomeapi==18.0.7 # homeassistant.components.flo aioflo==2021.11.0 From 27b5a9e074cf881a70d064106c8573264ea869bc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 08:09:59 -1000 Subject: [PATCH 624/968] Reduce number of test states in big purge test to fix CI (#102401) --- tests/components/recorder/test_purge.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index eedbd2c0e29..4faa8dc7e8a 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -74,16 +74,16 @@ async def test_purge_big_database( instance = await async_setup_recorder_instance(hass) - for _ in range(25): + for _ in range(12): await _add_test_states(hass, wait_recording_done=False) await async_wait_recording_done(hass) - with patch.object(instance, "max_bind_vars", 100), patch.object( - instance.database_engine, "max_bind_vars", 100 + with patch.object(instance, "max_bind_vars", 72), patch.object( + instance.database_engine, "max_bind_vars", 72 ), session_scope(hass=hass) as session: states = session.query(States) state_attributes = session.query(StateAttributes) - assert states.count() == 150 + assert states.count() == 72 assert state_attributes.count() == 3 purge_before = dt_util.utcnow() - timedelta(days=4) @@ -96,7 +96,7 @@ async def test_purge_big_database( repack=False, ) assert not finished - assert states.count() == 50 + assert states.count() == 24 assert state_attributes.count() == 1 From dd4ac823d390d1c33cb15ba4c402336eeee6817c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 20:20:04 +0200 Subject: [PATCH 625/968] Update pvo to 2.0.0 (#102398) --- homeassistant/components/pvoutput/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index b78f49b74f9..787e59db3db 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_polling", "quality_scale": "platinum", - "requirements": ["pvo==1.0.0"] + "requirements": ["pvo==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4bbe04449c5..ab134d1a1a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1501,7 +1501,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==1.0.0 +pvo==2.0.0 # homeassistant.components.canary py-canary==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6fb4726c11..6aee7925fa3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1146,7 +1146,7 @@ pushbullet.py==0.11.0 pushover_complete==1.1.1 # homeassistant.components.pvoutput -pvo==1.0.0 +pvo==2.0.0 # homeassistant.components.canary py-canary==0.5.3 From 5ff6779f5c63e633a9e5cbfb4571854f2caa476a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 20:20:17 +0200 Subject: [PATCH 626/968] Update guppy3 to 3.1.4 (#102400) --- homeassistant/components/profiler/__init__.py | 5 ----- .../components/profiler/manifest.json | 6 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/profiler/test_init.py | 22 ------------------- 5 files changed, 3 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 8b0252e7fa7..8c5c206ae9f 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -402,11 +402,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - if sys.version_info >= (3, 12): - raise HomeAssistantError( - "Memory profiling is not supported on Python 3.12. Please use Python 3.11." - ) - from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index eeb0a182ee3..d96f76d25a4 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,9 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": [ - "pyprof2calltree==1.4.5", - "guppy3==3.1.3;python_version<'3.12'", - "objgraph==3.5.0" - ] + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.4", "objgraph==3.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab134d1a1a4..b2dbedbce46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -948,7 +948,7 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.3;python_version<'3.12' +guppy3==3.1.4 # homeassistant.components.iaqualink h2==4.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6aee7925fa3..6ded0033ce7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -749,7 +749,7 @@ growattServer==1.3.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.3;python_version<'3.12' +guppy3==3.1.4 # homeassistant.components.iaqualink h2==4.1.0 diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 01f4c4ff510..7c2aeb2a29a 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -3,7 +3,6 @@ from datetime import timedelta from functools import lru_cache import os from pathlib import Path -import sys from unittest.mock import patch from lru import LRU # pylint: disable=no-name-in-module @@ -64,9 +63,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif( - sys.version_info >= (3, 12), reason="not yet available on Python 3.12" -) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -98,24 +94,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif(sys.version_info < (3, 12), reason="still works on python 3.11") -async def test_memory_usage_py312(hass: HomeAssistant, tmp_path: Path) -> None: - """Test raise an error on python3.11.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) - with pytest.raises( - HomeAssistantError, - match="Memory profiling is not supported on Python 3.12. Please use Python 3.11.", - ): - await hass.services.async_call( - DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True - ) - - async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 1bd0b2d05f2e4e53fd57f3a9faa195bebfffc396 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 20 Oct 2023 20:28:04 +0200 Subject: [PATCH 627/968] Refactor mqtt entity cleanup on reload (#102375) --- homeassistant/components/mqtt/__init__.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9f3fe6ef72b..abf4cc65dea 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -426,9 +426,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entity.async_remove() for mqtt_platform in mqtt_platforms for entity in mqtt_platform.entities.values() - # pylint: disable-next=protected-access - if not entity._discovery_data # type: ignore[attr-defined] - if mqtt_platform.config_entry + if getattr(entity, "_discovery_data", None) is None + and mqtt_platform.config_entry and mqtt_platform.domain in RELOADABLE_PLATFORMS ] await asyncio.gather(*tasks) From e1972ba3c8cad2b1c016dbab32de1cb4da9cfbc7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 20 Oct 2023 14:30:54 -0400 Subject: [PATCH 628/968] Add Enphase charge from grid switch (#102394) * Add Enphase charge from grid switch * review comments --- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/strings.json | 3 + .../components/enphase_envoy/switch.py | 84 ++++++++++++++++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 917e325be51..8788c95d3c6 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.11.4"], + "requirements": ["pyenphase==1.12.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 92eca38ef20..7c5d48edfe7 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -122,6 +122,9 @@ } }, "switch": { + "charge_from_grid": { + "name": "Charge from grid" + }, "grid_enabled": { "name": "Grid enabled" } diff --git a/homeassistant/components/enphase_envoy/switch.py b/homeassistant/components/enphase_envoy/switch.py index fb9e14406ac..22746fd9479 100644 --- a/homeassistant/components/enphase_envoy/switch.py +++ b/homeassistant/components/enphase_envoy/switch.py @@ -1,13 +1,15 @@ """Switch platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass import logging from typing import Any from pyenphase import Envoy, EnvoyDryContactStatus, EnvoyEnpower +from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import DryContactStatus +from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -54,6 +56,22 @@ class EnvoyDryContactSwitchEntityDescription( """Describes an Envoy Enpower dry contact switch entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], bool] + turn_on_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] + turn_off_fn: Callable[[Envoy], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsSwitchEntityDescription( + SwitchEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage settings switch entity.""" + + ENPOWER_GRID_SWITCH = EnvoyEnpowerSwitchEntityDescription( key="mains_admin_state", translation_key="grid_enabled", @@ -69,6 +87,14 @@ RELAY_STATE_SWITCH = EnvoyDryContactSwitchEntityDescription( turn_off_fn=lambda envoy, id: envoy.open_dry_contact(id), ) +CHARGE_FROM_GRID_SWITCH = EnvoyStorageSettingsSwitchEntityDescription( + key="charge_from_grid", + translation_key="charge_from_grid", + value_fn=lambda storage_settings: storage_settings.charge_from_grid, + turn_on_fn=lambda envoy: envoy.enable_charge_from_grid(), + turn_off_fn=lambda envoy: envoy.disable_charge_from_grid(), +) + async def async_setup_entry( hass: HomeAssistant, @@ -95,6 +121,18 @@ async def async_setup_entry( for relay in envoy_data.dry_contact_status ) + if ( + envoy_data.enpower + and envoy_data.tariff + and envoy_data.tariff.storage_settings + and (coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE) + ): + entities.append( + EnvoyStorageSettingsSwitchEntity( + coordinator, CHARGE_FROM_GRID_SWITCH, envoy_data.enpower + ) + ) + async_add_entities(entities) @@ -188,3 +226,47 @@ class EnvoyDryContactSwitchEntity(EnvoyBaseEntity, SwitchEntity): """Turn off (open) the dry contact.""" if await self.entity_description.turn_off_fn(self.envoy, self.relay_id): self.async_write_ha_state() + + +class EnvoyStorageSettingsSwitchEntity(EnvoyBaseEntity, SwitchEntity): + """Representation of an Enphase storage settings switch entity.""" + + entity_description: EnvoyStorageSettingsSwitchEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsSwitchEntityDescription, + enpower: EnvoyEnpower, + ) -> None: + """Initialize the Enphase storage settings switch entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + self.enpower = enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def is_on(self) -> bool: + """Return the state of the storage settings switch.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_turn_on(self): + """Turn on the storage settings switch.""" + await self.entity_description.turn_on_fn(self.envoy) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self): + """Turn off the storage switch.""" + await self.entity_description.turn_off_fn(self.envoy) + await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index b2dbedbce46..3fef16497e4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1691,7 +1691,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.4 +pyenphase==1.12.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ded0033ce7..415972285d3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.4 +pyenphase==1.12.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 038040ffbe859fa7bc0441c5949cc7f58349fc81 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 20:58:40 +0200 Subject: [PATCH 629/968] Update elgato to 5.0.0 (#102405) --- homeassistant/components/elgato/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elgato/manifest.json b/homeassistant/components/elgato/manifest.json index 28d8826a066..49340f028d0 100644 --- a/homeassistant/components/elgato/manifest.json +++ b/homeassistant/components/elgato/manifest.json @@ -7,6 +7,6 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["elgato==4.0.1"], + "requirements": ["elgato==5.0.0"], "zeroconf": ["_elg._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 3fef16497e4..3199c529217 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -731,7 +731,7 @@ ecoaliface==0.4.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==4.0.1 +elgato==5.0.0 # homeassistant.components.eliqonline eliqonline==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 415972285d3..d502c50b80b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -593,7 +593,7 @@ easyenergy==0.3.0 electrickiwi-api==0.8.5 # homeassistant.components.elgato -elgato==4.0.1 +elgato==5.0.0 # homeassistant.components.elkm1 elkm1-lib==2.2.6 From 6f845497846c3463d151124555921d9d14b3cc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 20 Oct 2023 20:59:02 +0200 Subject: [PATCH 630/968] Update aioairzone-cloud to v0.2.7 (#102406) --- homeassistant/components/airzone_cloud/climate.py | 3 ++- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airzone_cloud/climate.py b/homeassistant/components/airzone_cloud/climate.py index 8995967f9a0..1fe5e45ee44 100644 --- a/homeassistant/components/airzone_cloud/climate.py +++ b/homeassistant/components/airzone_cloud/climate.py @@ -372,7 +372,8 @@ class AirzoneZoneClimate(AirzoneZoneEntity, AirzoneDeviceClimate): } else: mode = HVAC_MODE_HASS_TO_LIB[hvac_mode] - if mode != self.get_airzone_value(AZD_MODE): + cur_mode = self.get_airzone_value(AZD_MODE) + if hvac_mode != HVAC_MODE_LIB_TO_HASS[cur_mode]: if self.get_airzone_value(AZD_MASTER): params[API_MODE] = { API_VALUE: mode.value, diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index e8f5d8d86bb..b8992a80ee3 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.5"] + "requirements": ["aioairzone-cloud==0.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3199c529217..616cbf65477 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.5 +aioairzone-cloud==0.2.7 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d502c50b80b..64422db9e67 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.5 +aioairzone-cloud==0.2.7 # homeassistant.components.airzone aioairzone==0.6.9 From f6238c16f6495f9f6bf124bd9d3729829eef309a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 23:00:17 +0200 Subject: [PATCH 631/968] Bump twentemilieu to 2.0.0 (#102407) --- homeassistant/components/twentemilieu/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twentemilieu/manifest.json b/homeassistant/components/twentemilieu/manifest.json index cfacc9072f2..6cb98444be6 100644 --- a/homeassistant/components/twentemilieu/manifest.json +++ b/homeassistant/components/twentemilieu/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["twentemilieu"], "quality_scale": "platinum", - "requirements": ["twentemilieu==1.0.0"] + "requirements": ["twentemilieu==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 616cbf65477..55fd4a3637b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2615,7 +2615,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==1.0.0 +twentemilieu==2.0.0 # homeassistant.components.twilio twilio==6.32.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64422db9e67..5238633d992 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1936,7 +1936,7 @@ ttls==1.5.1 tuya-iot-py-sdk==0.6.6 # homeassistant.components.twentemilieu -twentemilieu==1.0.0 +twentemilieu==2.0.0 # homeassistant.components.twilio twilio==6.32.0 From f08a3b96e4e64cb868c941322dd238927c2109d9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 23:06:13 +0200 Subject: [PATCH 632/968] Update wled to 0.17.0 (#102413) --- 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 b6d205912c6..b6e14963b9e 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.16.0"], + "requirements": ["wled==0.17.0"], "zeroconf": ["_wled._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 55fd4a3637b..5a012e07a56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2720,7 +2720,7 @@ wiffi==1.1.2 wirelesstagpy==0.8.1 # homeassistant.components.wled -wled==0.16.0 +wled==0.17.0 # homeassistant.components.wolflink wolf-smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5238633d992..cc944d5f078 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2023,7 +2023,7 @@ whois==0.9.27 wiffi==1.1.2 # homeassistant.components.wled -wled==0.16.0 +wled==0.17.0 # homeassistant.components.wolflink wolf-smartset==0.1.11 From b70262fe8c00432cae3ebe1a6e53668ba87863cc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 23:07:14 +0200 Subject: [PATCH 633/968] Update psutil to 5.9.6 (#102416) --- homeassistant/components/systemmonitor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json index e02d0421f8d..3bcbc75d3b7 100644 --- a/homeassistant/components/systemmonitor/manifest.json +++ b/homeassistant/components/systemmonitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/systemmonitor", "iot_class": "local_push", "loggers": ["psutil"], - "requirements": ["psutil==5.9.5"] + "requirements": ["psutil==5.9.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5a012e07a56..576eaac41bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ proxmoxer==2.0.1 psutil-home-assistant==0.0.1 # homeassistant.components.systemmonitor -psutil==5.9.5 +psutil==5.9.6 # homeassistant.components.pulseaudio_loopback pulsectl==23.5.2 From f51743f123451a0541a1a6dac52404f6e4360393 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 11:08:41 -1000 Subject: [PATCH 634/968] Bump aiohomekit to 3.0.7 (#102408) --- .../components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../specific_devices/test_koogeek_ls1.py | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9c989563b6a..3299bde21d3 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.6"], + "requirements": ["aiohomekit==3.0.7"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 576eaac41bb..5ac476fabac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.6 +aiohomekit==3.0.7 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cc944d5f078..9bbce9bb86c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.6 +aiohomekit==3.0.7 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index e25d5b7830e..2c2c0b5e1c5 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -7,6 +7,9 @@ from aiohomekit.model import CharacteristicsTypes, ServicesTypes from aiohomekit.testing import FakePairing import pytest +from homeassistant.components.homekit_controller.connection import ( + MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -26,8 +29,6 @@ async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> accessories = await setup_accessories_from_file(hass, "koogeek_ls1.json") config_entry, pairing = await setup_test_accessories(hass, accessories) - pairing.testing.events_enabled = False - helper = Helper( hass, "light.koogeek_ls1_20833f_light_strip", @@ -49,11 +50,10 @@ async def test_recover_from_failure(hass: HomeAssistant, utcnow, failure_cls) -> with mock.patch.object(FakePairing, "get_characteristics") as get_char: get_char.side_effect = failure_cls("Disconnected") - # Set light state on fake device to on - state = await helper.async_update( - ServicesTypes.LIGHTBULB, {CharacteristicsTypes.ON: True} - ) - assert state.state == "off" + # Test that a poll triggers unavailable + for _ in range(MAX_POLL_FAILURES_TO_DECLARE_UNAVAILABLE + 2): + state = await helper.poll_and_get_state() + assert state.state == "unavailable" chars = get_char.call_args[0][0] assert set(chars) == {(1, 8), (1, 9), (1, 10), (1, 11)} From b881057aa6ef2f610c0d86f73eadd35c5bffbc37 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 20 Oct 2023 23:45:44 +0200 Subject: [PATCH 635/968] Update apprise to 1.6.0 (#102417) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index e67192040a6..8132f3623a9 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.5.0"] + "requirements": ["apprise==1.6.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ac476fabac..272a3ce453a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -435,7 +435,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.4 # homeassistant.components.apprise -apprise==1.5.0 +apprise==1.6.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bbce9bb86c..d85eace91dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -401,7 +401,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.4 # homeassistant.components.apprise -apprise==1.5.0 +apprise==1.6.0 # homeassistant.components.aprs aprslib==0.7.0 From 3c455391c25d6ecdfbbe98f214bb9755dc3f94e3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 20 Oct 2023 23:46:33 +0200 Subject: [PATCH 636/968] Use dataclass to carry data in ping (#99803) Co-authored-by: J. Nick Koston --- CODEOWNERS | 2 ++ homeassistant/components/ping/__init__.py | 18 ++++++++++++++---- homeassistant/components/ping/binary_sensor.py | 8 ++++++-- homeassistant/components/ping/const.py | 2 -- .../components/ping/device_tracker.py | 7 +++++-- homeassistant/components/ping/manifest.json | 2 +- 6 files changed, 28 insertions(+), 11 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 95c5b7bedcf..6d2637c1f3e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -949,6 +949,8 @@ build.json @home-assistant/supervisor /tests/components/picnic/ @corneyl /homeassistant/components/pilight/ @trekky12 /tests/components/pilight/ @trekky12 +/homeassistant/components/ping/ @jpbede +/tests/components/ping/ @jpbede /homeassistant/components/plaato/ @JohNan /tests/components/plaato/ @JohNan /homeassistant/components/plex/ @jjlawren diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 3ff36f2e283..26dd8113231 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -1,6 +1,7 @@ """The ping component.""" from __future__ import annotations +from dataclasses import dataclass import logging from icmplib import SocketPermissionError, ping as icmp_ping @@ -10,19 +11,28 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, PING_PRIVS, PLATFORMS +from .const import DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = cv.platform_only_config_schema(DOMAIN) +@dataclass(slots=True) +class PingDomainData: + """Dataclass to store privileged status.""" + + privileged: bool | None + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ping integration.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - hass.data[DOMAIN] = { - PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), - } + + hass.data[DOMAIN] = PingDomainData( + privileged=await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), + ) + return True diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index 6a150b3dc4c..bab7f3a3735 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -23,7 +23,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, ICMP_TIMEOUT, PING_PRIVS, PING_TIMEOUT +from . import PingDomainData +from .const import DOMAIN, ICMP_TIMEOUT, PING_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -70,10 +71,13 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Ping Binary sensor.""" + + data: PingDomainData = hass.data[DOMAIN] + host: str = config[CONF_HOST] count: int = config[CONF_PING_COUNT] name: str = config.get(CONF_NAME, f"{DEFAULT_NAME} {host}") - privileged: bool | None = hass.data[DOMAIN][PING_PRIVS] + privileged: bool | None = data.privileged ping_cls: type[PingDataSubProcess | PingDataICMPLib] if privileged is None: ping_cls = PingDataSubProcess diff --git a/homeassistant/components/ping/const.py b/homeassistant/components/ping/const.py index 1a77c62fa5c..fd70a9340c2 100644 --- a/homeassistant/components/ping/const.py +++ b/homeassistant/components/ping/const.py @@ -16,5 +16,3 @@ PING_ATTEMPTS_COUNT = 3 DOMAIN = "ping" PLATFORMS = [Platform.BINARY_SENSOR] - -PING_PRIVS = "ping_privs" diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py index a25b3652b36..9a63a2f844d 100644 --- a/homeassistant/components/ping/device_tracker.py +++ b/homeassistant/components/ping/device_tracker.py @@ -25,7 +25,8 @@ from homeassistant.util import dt as dt_util from homeassistant.util.async_ import gather_with_limited_concurrency from homeassistant.util.process import kill_subprocess -from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_PRIVS, PING_TIMEOUT +from . import PingDomainData +from .const import DOMAIN, ICMP_TIMEOUT, PING_ATTEMPTS_COUNT, PING_TIMEOUT _LOGGER = logging.getLogger(__name__) @@ -97,7 +98,9 @@ async def async_setup_scanner( ) -> bool: """Set up the Host objects and return the update function.""" - privileged = hass.data[DOMAIN][PING_PRIVS] + data: PingDomainData = hass.data[DOMAIN] + + privileged = data.privileged ip_to_dev_id = {ip: dev_id for (dev_id, ip) in config[CONF_HOSTS].items()} interval = config.get( CONF_SCAN_INTERVAL, diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json index 9290c9992eb..e27c3a239d0 100644 --- a/homeassistant/components/ping/manifest.json +++ b/homeassistant/components/ping/manifest.json @@ -1,7 +1,7 @@ { "domain": "ping", "name": "Ping (ICMP)", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/ping", "iot_class": "local_polling", "loggers": ["icmplib"], From 7e6c14d062a13de00f4212d1b1cb491c25059626 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Oct 2023 00:26:02 +0200 Subject: [PATCH 637/968] Update Pillow to 10.1.0 (#102419) --- homeassistant/components/doods/manifest.json | 2 +- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/image_upload/manifest.json | 2 +- homeassistant/components/matrix/manifest.json | 2 +- homeassistant/components/proxy/manifest.json | 2 +- homeassistant/components/qrcode/manifest.json | 2 +- homeassistant/components/seven_segments/manifest.json | 2 +- homeassistant/components/sighthound/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json index 12397eb8990..e49f525d0c2 100644 --- a/homeassistant/components/doods/manifest.json +++ b/homeassistant/components/doods/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/doods", "iot_class": "local_polling", "loggers": ["pydoods"], - "requirements": ["pydoods==1.0.2", "Pillow==10.0.1"] + "requirements": ["pydoods==1.0.2", "Pillow==10.1.0"] } diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 2966d668ac9..7ac9c5d406f 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.1.1", "Pillow==10.0.1"] + "requirements": ["ha-av==10.1.1", "Pillow==10.1.0"] } diff --git a/homeassistant/components/image_upload/manifest.json b/homeassistant/components/image_upload/manifest.json index b6c74f0c53c..5ffeec1ca41 100644 --- a/homeassistant/components/image_upload/manifest.json +++ b/homeassistant/components/image_upload/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/image_upload", "integration_type": "system", "quality_scale": "internal", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 5e1f6fa111c..a68741d4c33 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-nio==0.22.1", "Pillow==10.0.1"] + "requirements": ["matrix-nio==0.22.1", "Pillow==10.1.0"] } diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json index b5b25a66342..59798e38957 100644 --- a/homeassistant/components/proxy/manifest.json +++ b/homeassistant/components/proxy/manifest.json @@ -3,5 +3,5 @@ "name": "Camera Proxy", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/proxy", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json index f1f40dd8973..23bd1d050a1 100644 --- a/homeassistant/components/qrcode/manifest.json +++ b/homeassistant/components/qrcode/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/qrcode", "iot_class": "calculated", "loggers": ["pyzbar"], - "requirements": ["Pillow==10.0.1", "pyzbar==0.1.7"] + "requirements": ["Pillow==10.1.0", "pyzbar==0.1.7"] } diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json index 2b730648e22..80b428b908e 100644 --- a/homeassistant/components/seven_segments/manifest.json +++ b/homeassistant/components/seven_segments/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/seven_segments", "iot_class": "local_polling", - "requirements": ["Pillow==10.0.1"] + "requirements": ["Pillow==10.1.0"] } diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json index d1bc97da7a8..208e2d31de4 100644 --- a/homeassistant/components/sighthound/manifest.json +++ b/homeassistant/components/sighthound/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/sighthound", "iot_class": "cloud_polling", "loggers": ["simplehound"], - "requirements": ["Pillow==10.0.1", "simplehound==0.3"] + "requirements": ["Pillow==10.1.0", "simplehound==0.3"] } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index c8682941e28..39083434e89 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -10,6 +10,6 @@ "tf-models-official==2.5.0", "pycocotools==2.0.6", "numpy==1.26.0", - "Pillow==10.0.1" + "Pillow==10.1.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1024239217a..c3761418217 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ mutagen==1.47.0 orjson==3.9.9 packaging>=23.1 paho-mqtt==1.6.1 -Pillow==10.0.1 +Pillow==10.1.0 pip>=21.3.1 psutil-home-assistant==0.0.1 PyJWT==2.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 272a3ce453a..58c8f7fb6ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -43,7 +43,7 @@ Mastodon.py==1.5.1 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.1 +Pillow==10.1.0 # homeassistant.components.plex PlexAPI==4.15.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d85eace91dd..1b2c2e9d249 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -39,7 +39,7 @@ HATasmota==0.7.3 # homeassistant.components.seven_segments # homeassistant.components.sighthound # homeassistant.components.tensorflow -Pillow==10.0.1 +Pillow==10.1.0 # homeassistant.components.plex PlexAPI==4.15.4 From 55a8f01dcf5fc35f8beeb1762cd9ad31a50a225f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Oct 2023 00:33:07 +0200 Subject: [PATCH 638/968] Update ruff to v0.1.1 (#102421) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 82e8be48db3..69d5ef28810 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.0.292 + rev: v0.1.1 hooks: - id: ruff args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index f3780898ea7..2b71ead7d41 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -2,5 +2,5 @@ black==23.9.1 codespell==2.2.2 -ruff==0.0.292 +ruff==0.1.1 yamllint==1.32.0 From a2c60d9015382bec293899904e0f4c37c967c33c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 12:46:18 -1000 Subject: [PATCH 639/968] Only callback when value or status changes for processing HKC events (#102370) --- .../homekit_controller/connection.py | 4 +-- .../components/homekit_controller/entity.py | 6 ++++- tests/components/homekit_controller/common.py | 7 ++++++ .../homekit_controller/test_sensor.py | 25 +++++++++++++++++-- 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 923dfd8f96b..1d0eb9cdd83 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -833,10 +833,8 @@ class HKDevice: # Process any stateless events (via device_triggers) async_fire_triggers(self, new_values_dict) - self.entity_map.process_changes(new_values_dict) - to_callback: set[CALLBACK_TYPE] = set() - for aid_iid in new_values_dict: + for aid_iid in self.entity_map.process_changes(new_values_dict): if callbacks := self._subscriptions.get(aid_iid): to_callback.update(callbacks) diff --git a/homeassistant/components/homekit_controller/entity.py b/homeassistant/components/homekit_controller/entity.py index 7511f95e283..d1f48a67e7f 100644 --- a/homeassistant/components/homekit_controller/entity.py +++ b/homeassistant/components/homekit_controller/entity.py @@ -219,7 +219,11 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.available and self.service.available + return self._accessory.available and all( + c.available + for c in self.service.characteristics + if (self._aid, c.iid) in self.all_characteristics + ) @property def device_info(self) -> DeviceInfo: diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index c3e6b5505d3..a5219fe7018 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -155,6 +155,13 @@ class Helper: assert state is not None return state + async def async_set_aid_iid_status( + self, aid_iid_status: list[tuple[int, int, int]] + ) -> None: + """Set the status of a set of aid/iid pairs.""" + self.pairing.testing.set_aid_iid_status(aid_iid_status) + await self.hass.async_block_till_done() + @callback def async_assert_service_values( self, service: str, characteristics: dict[str, Any] diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 6c9ad008703..829fe8e3cdc 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -311,10 +311,9 @@ async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: """Test a sensor becoming unavailable.""" helper = await setup_test_component(hass, create_switch_with_sensor) - # Find the energy sensor and mark it as offline outlet = helper.accessory.services.first(service_type=ServicesTypes.OUTLET) + on_char = outlet[CharacteristicsTypes.ON] realtime_energy = outlet[CharacteristicsTypes.VENDOR_KOOGEEK_REALTIME_ENERGY] - realtime_energy.status = HapStatusCode.UNABLE_TO_COMMUNICATE # Helper will be for the primary entity, which is the outlet. Make a helper for the sensor. energy_helper = Helper( @@ -325,10 +324,32 @@ async def test_sensor_unavailable(hass: HomeAssistant, utcnow) -> None: helper.config_entry, ) + # Find the outlet on char and mark it as offline + await helper.async_set_aid_iid_status( + [ + ( + helper.accessory.aid, + on_char.iid, + HapStatusCode.UNABLE_TO_COMMUNICATE.value, + ) + ] + ) + # Outlet has non-responsive characteristics so should be unavailable state = await helper.poll_and_get_state() assert state.state == "unavailable" + # Find the energy sensor and mark it as offline + await helper.async_set_aid_iid_status( + [ + ( + energy_helper.accessory.aid, + realtime_energy.iid, + HapStatusCode.UNABLE_TO_COMMUNICATE.value, + ) + ] + ) + # Energy sensor has non-responsive characteristics so should be unavailable state = await energy_helper.poll_and_get_state() assert state.state == "unavailable" From 41b59b6990b9caca5c61947f3d0dbbbf3ba4def1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 20 Oct 2023 19:11:08 -0400 Subject: [PATCH 640/968] Add support for zwave_js event entities (#102285) Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 14 + homeassistant/components/zwave_js/event.py | 98 ++++ tests/components/zwave_js/conftest.py | 14 + .../fixtures/central_scene_node_state.json | 431 ++++++++++++++++++ tests/components/zwave_js/test_event.py | 175 +++++++ tests/components/zwave_js/test_events.py | 2 +- 6 files changed, 733 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/zwave_js/event.py create mode 100644 tests/components/zwave_js/fixtures/central_scene_node_state.json create mode 100644 tests/components/zwave_js/test_event.py diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 46975631523..39d8c0e8855 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -162,6 +162,8 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): any_available_states: set[tuple[int, str]] | None = None # [optional] the value's value must match this value value: Any | None = None + # [optional] the value's metadata_stateful must match this value + stateful: bool | None = None @dataclass @@ -1045,6 +1047,15 @@ DISCOVERY_SCHEMAS = [ any_available_states={(0, "idle")}, ), ), + # event + # stateful = False + ZWaveDiscoverySchema( + platform=Platform.EVENT, + hint="stateless", + primary_value=ZWaveValueDiscoverySchema( + stateful=False, + ), + ), ] @@ -1294,6 +1305,9 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: # check value if schema.value is not None and value.value not in schema.value: return False + # check metadata_stateful + if schema.stateful is not None and value.metadata.stateful != schema.stateful: + return False return True diff --git a/homeassistant/components/zwave_js/event.py b/homeassistant/components/zwave_js/event.py new file mode 100644 index 00000000000..93860b6273e --- /dev/null +++ b/homeassistant/components/zwave_js/event.py @@ -0,0 +1,98 @@ +"""Support for Z-Wave controls using the event platform.""" +from __future__ import annotations + +from zwave_js_server.client import Client as ZwaveClient +from zwave_js_server.model.driver import Driver +from zwave_js_server.model.value import Value, ValueNotification + +from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_VALUE, DATA_CLIENT, DOMAIN +from .discovery import ZwaveDiscoveryInfo +from .entity import ZWaveBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Z-Wave Event entity from Config Entry.""" + client: ZwaveClient = hass.data[DOMAIN][config_entry.entry_id][DATA_CLIENT] + + @callback + def async_add_event(info: ZwaveDiscoveryInfo) -> None: + """Add Z-Wave event entity.""" + driver = client.driver + assert driver is not None # Driver is ready before platforms are loaded. + entities: list[ZWaveBaseEntity] = [ZwaveEventEntity(config_entry, driver, info)] + async_add_entities(entities) + + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_{EVENT_DOMAIN}", + async_add_event, + ) + ) + + +def _cc_and_label(value: Value) -> str: + """Return a string with the command class and label.""" + label = value.metadata.label + if label: + label = label.lower() + return f"{value.command_class_name.capitalize()} {label}".strip() + + +class ZwaveEventEntity(ZWaveBaseEntity, EventEntity): + """Representation of a Z-Wave event entity.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZwaveEventEntity entity.""" + super().__init__(config_entry, driver, info) + value = self.value = info.primary_value + self.states: dict[int, str] = {} + + if states := value.metadata.states: + self._attr_event_types = sorted(states.values()) + self.states = {int(k): v for k, v in states.items()} + else: + self._attr_event_types = [_cc_and_label(value)] + # Entity class attributes + self._attr_name = self.generate_name(include_value_name=True) + + @callback + def _async_handle_event(self, value_notification: ValueNotification) -> None: + """Handle a value notification event.""" + # If the notification doesn't match the value we are tracking, we can return + value = self.value + if ( + value_notification.command_class != value.command_class + or value_notification.endpoint != value.endpoint + or value_notification.property_ != value.property_ + or value_notification.property_key != value.property_key + or (notification_value := value_notification.value) is None + ): + return + event_name = self.states.get(notification_value, _cc_and_label(value)) + self._trigger_event(event_name, {ATTR_VALUE: notification_value}) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + await super().async_added_to_hass() + self.async_on_remove( + self.info.node.on( + "value notification", + lambda event: self._async_handle_event(event["value_notification"]), + ) + ) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index b9feeab1f2f..db5495bce01 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -668,6 +668,12 @@ def climate_intermatic_pe653_state_fixture(): return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) +@pytest.fixture(name="central_scene_node_state", scope="session") +def central_scene_node_state_fixture(): + """Load node with Central Scene CC node state fixture data.""" + return json.loads(load_fixture("zwave_js/central_scene_node_state.json")) + + # model fixtures @@ -1304,3 +1310,11 @@ def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="central_scene_node") +def central_scene_node_fixture(client, central_scene_node_state): + """Mock a node with the Central Scene CC.""" + node = Node(client, copy.deepcopy(central_scene_node_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/central_scene_node_state.json b/tests/components/zwave_js/fixtures/central_scene_node_state.json new file mode 100644 index 00000000000..1fb01275ccf --- /dev/null +++ b/tests/components/zwave_js/fixtures/central_scene_node_state.json @@ -0,0 +1,431 @@ +{ + "nodeId": 51, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "firmwareVersion": "1.3.0", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 51, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + }, + "stateful": false, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "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": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["1.3"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "6.81.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "1.3.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 255 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 42, + "commandsRX": 46, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 55.4, + "rssi": -72, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -72, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 0, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_event.py b/tests/components/zwave_js/test_event.py new file mode 100644 index 00000000000..12187d3d227 --- /dev/null +++ b/tests/components/zwave_js/test_event.py @@ -0,0 +1,175 @@ +"""Test the Z-Wave JS event platform.""" +from datetime import timedelta + +from freezegun import freeze_time +from zwave_js_server.event import Event + +from homeassistant.components.event import ATTR_EVENT_TYPE +from homeassistant.components.zwave_js.const import ATTR_VALUE +from homeassistant.const import STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +BASIC_EVENT_VALUE_ENTITY = "event.honeywell_in_wall_smart_fan_control_event_value" +CENTRAL_SCENE_ENTITY = "event.node_51_scene_002" + + +async def test_basic( + hass: HomeAssistant, client, fan_honeywell_39358, integration +) -> None: + """Test the Basic CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = fan_honeywell_39358 + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "commandClassName": "Basic", + "commandClass": 32, + "endpoint": 0, + "property": "event", + "propertyName": "event", + "value": 255, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "min": 0, + "max": 255, + "label": "Event value", + }, + "ccVersion": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(BASIC_EVENT_VALUE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "Basic event value" + assert attributes[ATTR_VALUE] == 255 + + +async def test_central_scene( + hass: HomeAssistant, client, central_scene_node, integration +) -> None: + """Test the Central Scene CC event entity.""" + dt_util.now() + fut = dt_util.now() + timedelta(minutes=1) + node = central_scene_node + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + "value": 1, + }, + }, + ) + with freeze_time(fut): + node.receive_event(event) + + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 + + # Try invalid value + event = Event( + type="value notification", + data={ + "source": "node", + "event": "value notification", + "nodeId": node.node_id, + "args": { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": True, + "writeable": False, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x", + }, + "stateful": False, + "secret": False, + }, + }, + }, + ) + with freeze_time(fut + timedelta(minutes=10)): + node.receive_event(event) + + # Nothing should have changed even though the time has changed + state = hass.states.get(CENTRAL_SCENE_ENTITY) + + assert state + assert state.state == dt_util.as_utc(fut).isoformat(timespec="milliseconds") + attributes = state.attributes + assert attributes[ATTR_EVENT_TYPE] == "KeyReleased" + assert attributes[ATTR_VALUE] == 1 diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 80b179248d8..4fbaa97f118 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -1,4 +1,4 @@ -"""Test Z-Wave JS (value notification) events.""" +"""Test Z-Wave JS events.""" from unittest.mock import AsyncMock import pytest From 013e580c02fdbbf8b99c4f0e818fe3d8d3594055 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Fri, 20 Oct 2023 20:05:42 -0400 Subject: [PATCH 641/968] Add support for changing Enphase battery backup modes (#102392) --- .../components/enphase_envoy/manifest.json | 2 +- .../components/enphase_envoy/number.py | 80 +++++++++++++++++- .../components/enphase_envoy/select.py | 84 ++++++++++++++++++- .../components/enphase_envoy/strings.json | 11 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 174 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json index 8788c95d3c6..c524a2421c3 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.12.0"], + "requirements": ["pyenphase==1.13.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/homeassistant/components/enphase_envoy/number.py b/homeassistant/components/enphase_envoy/number.py index 50d4de18f12..918e4002e7a 100644 --- a/homeassistant/components/enphase_envoy/number.py +++ b/homeassistant/components/enphase_envoy/number.py @@ -1,10 +1,13 @@ """Number platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Awaitable, Callable from dataclasses import dataclass +from typing import Any -from pyenphase import EnvoyDryContactSettings +from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures +from pyenphase.models.tariff import EnvoyStorageSettings from homeassistant.components.number import ( NumberDeviceClass, @@ -12,7 +15,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,6 +39,21 @@ class EnvoyRelayNumberEntityDescription( """Describes an Envoy Dry Contact Relay number entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], float] + update_fn: Callable[[Envoy, float], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsNumberEntityDescription( + NumberEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage mode number entity.""" + + RELAY_ENTITIES = ( EnvoyRelayNumberEntityDescription( key="soc_low", @@ -53,6 +71,15 @@ RELAY_ENTITIES = ( ), ) +STORAGE_RESERVE_SOC_ENTITY = EnvoyStorageSettingsNumberEntityDescription( + key="reserve_soc", + translation_key="reserve_soc", + native_unit_of_measurement=PERCENTAGE, + device_class=NumberDeviceClass.BATTERY, + value_fn=lambda storage_settings: storage_settings.reserved_soc, + update_fn=lambda envoy, value: envoy.set_reserve_soc(int(value)), +) + async def async_setup_entry( hass: HomeAssistant, @@ -70,6 +97,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsNumberEntity(coordinator, STORAGE_RESERVE_SOC_ENTITY) + ) async_add_entities(entities) @@ -114,3 +149,42 @@ class EnvoyRelayNumberEntity(EnvoyBaseEntity, NumberEntity): {"id": self._relay_id, self.entity_description.key: int(value)} ) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsNumberEntity(EnvoyBaseEntity, NumberEntity): + """Representation of an Enphase storage settings number entity.""" + + entity_description: EnvoyStorageSettingsNumberEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsNumberEntityDescription, + ) -> None: + """Initialize the Enphase relay number entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert self.data.enpower is not None + enpower = self.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def native_value(self) -> float: + """Return the state of the storage setting entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_set_native_value(self, value: float) -> None: + """Update the storage setting.""" + await self.entity_description.update_fn(self.envoy, value) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/select.py b/homeassistant/components/enphase_envoy/select.py index 5ae73a315f2..331d2a999ad 100644 --- a/homeassistant/components/enphase_envoy/select.py +++ b/homeassistant/components/enphase_envoy/select.py @@ -1,12 +1,14 @@ """Select platform for Enphase Envoy solar energy monitor.""" from __future__ import annotations -from collections.abc import Callable, Coroutine +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from typing import Any from pyenphase import Envoy, EnvoyDryContactSettings +from pyenphase.const import SupportedFeatures from pyenphase.models.dry_contacts import DryContactAction, DryContactMode +from pyenphase.models.tariff import EnvoyStorageMode, EnvoyStorageSettings from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -36,6 +38,21 @@ class EnvoyRelaySelectEntityDescription( """Describes an Envoy Dry Contact Relay select entity.""" +@dataclass +class EnvoyStorageSettingsRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[EnvoyStorageSettings], str] + update_fn: Callable[[Envoy, str], Awaitable[dict[str, Any]]] + + +@dataclass +class EnvoyStorageSettingsSelectEntityDescription( + SelectEntityDescription, EnvoyStorageSettingsRequiredKeysMixin +): + """Describes an Envoy storage settings select entity.""" + + RELAY_MODE_MAP = { DryContactMode.MANUAL: "standard", DryContactMode.STATE_OF_CHARGE: "battery", @@ -51,6 +68,14 @@ REVERSE_RELAY_ACTION_MAP = {v: k for k, v in RELAY_ACTION_MAP.items()} MODE_OPTIONS = list(REVERSE_RELAY_MODE_MAP) ACTION_OPTIONS = list(REVERSE_RELAY_ACTION_MAP) +STORAGE_MODE_MAP = { + EnvoyStorageMode.BACKUP: "backup", + EnvoyStorageMode.SELF_CONSUMPTION: "self_consumption", + EnvoyStorageMode.SAVINGS: "savings", +} +REVERSE_STORAGE_MODE_MAP = {v: k for k, v in STORAGE_MODE_MAP.items()} +STORAGE_MODE_OPTIONS = list(REVERSE_STORAGE_MODE_MAP) + RELAY_ENTITIES = ( EnvoyRelaySelectEntityDescription( key="mode", @@ -101,6 +126,15 @@ RELAY_ENTITIES = ( ), ), ) +STORAGE_MODE_ENTITY = EnvoyStorageSettingsSelectEntityDescription( + key="storage_mode", + translation_key="storage_mode", + options=STORAGE_MODE_OPTIONS, + value_fn=lambda storage_settings: STORAGE_MODE_MAP[storage_settings.mode], + update_fn=lambda envoy, value: envoy.set_storage_mode( + REVERSE_STORAGE_MODE_MAP[value] + ), +) async def async_setup_entry( @@ -119,6 +153,14 @@ async def async_setup_entry( for entity in RELAY_ENTITIES for relay in envoy_data.dry_contact_settings ) + if ( + envoy_data.tariff + and envoy_data.tariff.storage_settings + and coordinator.envoy.supported_features & SupportedFeatures.ENCHARGE + ): + entities.append( + EnvoyStorageSettingsSelectEntity(coordinator, STORAGE_MODE_ENTITY) + ) async_add_entities(entities) @@ -164,3 +206,43 @@ class EnvoyRelaySelectEntity(EnvoyBaseEntity, SelectEntity): """Update the relay.""" await self.entity_description.update_fn(self.envoy, self.relay, option) await self.coordinator.async_request_refresh() + + +class EnvoyStorageSettingsSelectEntity(EnvoyBaseEntity, SelectEntity): + """Representation of an Enphase storage settings select entity.""" + + entity_description: EnvoyStorageSettingsSelectEntityDescription + + def __init__( + self, + coordinator: EnphaseUpdateCoordinator, + description: EnvoyStorageSettingsSelectEntityDescription, + ) -> None: + """Initialize the Enphase storage settings select entity.""" + super().__init__(coordinator, description) + self.envoy = coordinator.envoy + assert coordinator.envoy.data is not None + assert coordinator.envoy.data.enpower is not None + enpower = coordinator.envoy.data.enpower + self._serial_number = enpower.serial_number + self._attr_unique_id = f"{self._serial_number}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._serial_number)}, + manufacturer="Enphase", + model="Enpower", + name=f"Enpower {self._serial_number}", + sw_version=str(enpower.firmware_version), + via_device=(DOMAIN, self.envoy_serial_num), + ) + + @property + def current_option(self) -> str: + """Return the state of the select entity.""" + assert self.data.tariff is not None + assert self.data.tariff.storage_settings is not None + return self.entity_description.value_fn(self.data.tariff.storage_settings) + + async def async_select_option(self, option: str) -> None: + """Update the relay.""" + await self.entity_description.update_fn(self.envoy, option) + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index 7c5d48edfe7..94cf9233745 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -39,6 +39,9 @@ }, "restore_battery_level": { "name": "Restore battery level" + }, + "reserve_soc": { + "name": "Reserve battery level" } }, "select": { @@ -75,6 +78,14 @@ "schedule": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::schedule%]", "none": "[%key:component::enphase_envoy::entity::select::relay_grid_action::state::none%]" } + }, + "storage_mode": { + "name": "Storage mode", + "state": { + "self_consumption": "Self consumption", + "backup": "Full backup", + "savings": "Savings mode" + } } }, "sensor": { diff --git a/requirements_all.txt b/requirements_all.txt index 58c8f7fb6ed..464fe28ef8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1691,7 +1691,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.12.0 +pyenphase==1.13.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b2c2e9d249..62bac0f7a1b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.12.0 +pyenphase==1.13.0 # homeassistant.components.everlights pyeverlights==0.1.0 From 5aefe963ae0fcdc854b6b691dbc23b6b096e1774 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sat, 21 Oct 2023 02:18:09 +0200 Subject: [PATCH 642/968] Bump bimmer_connected to 0.14.2 (#102426) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index d64541d73be..b5652694120 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected==0.14.1"] + "requirements": ["bimmer-connected==0.14.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 464fe28ef8d..2c0c4e5e4b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ beautifulsoup4==4.12.2 bellows==0.36.7 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.1 +bimmer-connected==0.14.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62bac0f7a1b..8a2e61c19ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -448,7 +448,7 @@ beautifulsoup4==4.12.2 bellows==0.36.7 # homeassistant.components.bmw_connected_drive -bimmer-connected==0.14.1 +bimmer-connected==0.14.2 # homeassistant.components.bluetooth bleak-retry-connector==3.2.1 From 29f61349eaefb7c14f80a04ed1e53b2e2f3438c8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 21 Oct 2023 02:19:04 +0200 Subject: [PATCH 643/968] Update black to 23.10.0 (#102420) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 69d5ef28810..d9cca711131 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: args: - --fix - repo: https://github.com/psf/black-pre-commit-mirror - rev: 23.9.1 + rev: 23.10.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 2b71ead7d41..8891e6e210d 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit -black==23.9.1 +black==23.10.0 codespell==2.2.2 ruff==0.1.1 yamllint==1.32.0 From 5cec687247faec9ccdd66d436870f697e35bed04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 20 Oct 2023 15:27:54 -1000 Subject: [PATCH 644/968] Bump pyenphase to 1.13.1 (#102431) --- 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 c524a2421c3..0700bd4e71a 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.13.0"], + "requirements": ["pyenphase==1.13.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 2c0c4e5e4b1..ba07ce23a2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1691,7 +1691,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.0 +pyenphase==1.13.1 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a2e61c19ad..39fa2b67f6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1273,7 +1273,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.13.0 +pyenphase==1.13.1 # homeassistant.components.everlights pyeverlights==0.1.0 From 09a8b8567d752dd3a9aa821a924ed2597ea4a7b2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 21 Oct 2023 02:31:00 -0400 Subject: [PATCH 645/968] Set Reolink record switch as config (#102439) Reolink: record switch as config --- homeassistant/components/reolink/switch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/reolink/switch.py b/homeassistant/components/reolink/switch.py index 4a5b415a144..4bc817f9c52 100644 --- a/homeassistant/components/reolink/switch.py +++ b/homeassistant/components/reolink/switch.py @@ -129,6 +129,7 @@ SWITCH_ENTITIES = ( key="record", translation_key="record", icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, supported=lambda api, ch: api.supported(ch, "recording") and api.is_nvr, value=lambda api, ch: api.recording_enabled(ch), method=lambda api, ch, value: api.set_recording(ch, value), @@ -185,6 +186,7 @@ NVR_SWITCH_ENTITIES = ( key="record", translation_key="record", icon="mdi:record-rec", + entity_category=EntityCategory.CONFIG, supported=lambda api: api.supported(None, "recording"), value=lambda api: api.recording_enabled(), method=lambda api, value: api.set_recording(None, value), From a8f0a66c27d147cf8ac2db939f9648dd02715058 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 21 Oct 2023 13:15:43 +0200 Subject: [PATCH 646/968] Fix idasen_desk generic typing (#102445) --- homeassistant/components/idasen_desk/__init__.py | 2 +- homeassistant/components/idasen_desk/cover.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index 27e7e872fd5..fb905bc6c4f 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -29,7 +29,7 @@ PLATFORMS: list[Platform] = [Platform.COVER] _LOGGER = logging.getLogger(__name__) -class IdasenDeskCoordinator(DataUpdateCoordinator): +class IdasenDeskCoordinator(DataUpdateCoordinator[int | None]): """Class to manage updates for the Idasen Desk.""" def __init__( diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py index 94f1b4a8cda..3148616d182 100644 --- a/homeassistant/components/idasen_desk/cover.py +++ b/homeassistant/components/idasen_desk/cover.py @@ -32,7 +32,7 @@ async def async_setup_entry( ) -class IdasenDeskCover(CoordinatorEntity, CoverEntity): +class IdasenDeskCover(CoordinatorEntity[IdasenDeskCoordinator], CoverEntity): """Representation of Idasen Desk device.""" _attr_device_class = CoverDeviceClass.DAMPER From 9f3a733f7344008c05084ada4b7c25803a3d8adc Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:19:32 +0200 Subject: [PATCH 647/968] Add sensor tests to Minecraft Server (#102418) --- .coveragerc | 4 - .../snapshots/test_sensor.ambr | 413 ++++++++++++++++++ .../minecraft_server/test_sensor.py | 239 ++++++++++ 3 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 tests/components/minecraft_server/snapshots/test_sensor.ambr create mode 100644 tests/components/minecraft_server/test_sensor.py diff --git a/.coveragerc b/.coveragerc index e682e1741e8..b6940d4ff08 100644 --- a/.coveragerc +++ b/.coveragerc @@ -745,11 +745,7 @@ omit = homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py - homeassistant/components/minecraft_server/api.py homeassistant/components/minecraft_server/binary_sensor.py - homeassistant/components/minecraft_server/coordinator.py - homeassistant/components/minecraft_server/entity.py - homeassistant/components/minecraft_server/sensor.py homeassistant/components/minio/minio_helper.py homeassistant/components/mjpeg/camera.py homeassistant/components/mjpeg/util.py diff --git a/tests/components/minecraft_server/snapshots/test_sensor.ambr b/tests/components/minecraft_server/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..fed0ae93c66 --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_sensor.ambr @@ -0,0 +1,413 @@ +# serializer version: 1 +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Map name', + 'icon': 'mdi:map', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_map_name', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Map Name', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Game mode', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_game_mode', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Game Mode', + }) +# --- +# name: test_sensor[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Edition', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_edition', + 'last_changed': , + 'last_updated': , + 'state': 'MCPE', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'players_list': list([ + 'Player 1', + 'Player 2', + 'Player 3', + ]), + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].6 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Map name', + 'icon': 'mdi:map', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_map_name', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Map Name', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].7 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Game mode', + 'icon': 'mdi:cog', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_game_mode', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Game Mode', + }) +# --- +# name: test_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1-entity_ids1].8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Edition', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_edition', + 'last_changed': , + 'last_updated': , + 'state': 'MCPE', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Latency', + 'icon': 'mdi:signal', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_latency', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].1 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players online', + 'icon': 'mdi:account-multiple', + 'players_list': list([ + 'Player 1', + 'Player 2', + 'Player 3', + ]), + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_online', + 'last_changed': , + 'last_updated': , + 'state': '3', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Players max', + 'icon': 'mdi:account-multiple', + 'unit_of_measurement': 'players', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_players_max', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].3 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server World message', + 'icon': 'mdi:minecraft', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_world_message', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy MOTD', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].4 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_version', + 'last_changed': , + 'last_updated': , + 'state': 'Dummy Version', + }) +# --- +# name: test_sensor_update[java_mock_config_entry-JavaServer-status_response0-entity_ids0].5 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Minecraft Server Protocol version', + 'icon': 'mdi:numeric', + }), + 'context': , + 'entity_id': 'sensor.minecraft_server_protocol_version', + 'last_changed': , + 'last_updated': , + 'state': '123', + }) +# --- diff --git a/tests/components/minecraft_server/test_sensor.py b/tests/components/minecraft_server/test_sensor.py new file mode 100644 index 00000000000..006c735e034 --- /dev/null +++ b/tests/components/minecraft_server/test_sensor.py @@ -0,0 +1,239 @@ +"""Tests for Minecraft Server sensors.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import async_fire_time_changed + +JAVA_SENSOR_ENTITIES: list[str] = [ + "sensor.minecraft_server_latency", + "sensor.minecraft_server_players_online", + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_world_message", + "sensor.minecraft_server_version", + "sensor.minecraft_server_protocol_version", +] + +JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_protocol_version", +] + +BEDROCK_SENSOR_ENTITIES: list[str] = [ + "sensor.minecraft_server_latency", + "sensor.minecraft_server_players_online", + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_world_message", + "sensor.minecraft_server_version", + "sensor.minecraft_server_protocol_version", + "sensor.minecraft_server_map_name", + "sensor.minecraft_server_game_mode", + "sensor.minecraft_server_edition", +] + +BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT: list[str] = [ + "sensor.minecraft_server_players_max", + "sensor.minecraft_server_protocol_version", + "sensor.minecraft_server_edition", +] + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id) == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES_DISABLED_BY_DEFAULT, + ), + ], +) +async def test_sensor_disabled_by_default( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, +) -> None: + """Test sensor, which is disabled by default.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert not hass.states.get(entity_id) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor_update( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id) == snapshot + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response", "entity_ids"), + [ + ( + "java_mock_config_entry", + JavaServer, + TEST_JAVA_STATUS_RESPONSE, + JAVA_SENSOR_ENTITIES, + ), + ( + "bedrock_mock_config_entry", + BedrockServer, + TEST_BEDROCK_STATUS_RESPONSE, + BEDROCK_SENSOR_ENTITIES, + ), + ], +) +async def test_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + entity_ids: list[str], + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + side_effect=OSError, + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + for entity_id in entity_ids: + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE From faa149b71a8ff6e628d69d4700c4681bc7e73ed2 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 21 Oct 2023 15:49:38 +0200 Subject: [PATCH 648/968] Add binary sensor tests to Minecraft Server (#102457) --- .coveragerc | 1 - .../snapshots/test_binary_sensor.ambr | 57 ++++++++ .../minecraft_server/test_binary_sensor.py | 128 ++++++++++++++++++ 3 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 tests/components/minecraft_server/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/minecraft_server/test_binary_sensor.py diff --git a/.coveragerc b/.coveragerc index b6940d4ff08..92bc1b47ad1 100644 --- a/.coveragerc +++ b/.coveragerc @@ -745,7 +745,6 @@ omit = homeassistant/components/mikrotik/hub.py homeassistant/components/mill/climate.py homeassistant/components/mill/sensor.py - homeassistant/components/minecraft_server/binary_sensor.py homeassistant/components/minio/minio_helper.py homeassistant/components/mjpeg/camera.py homeassistant/components/mjpeg/util.py diff --git a/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000..ef03e36343b --- /dev/null +++ b/tests/components/minecraft_server/snapshots/test_binary_sensor.ambr @@ -0,0 +1,57 @@ +# serializer version: 1 +# name: test_binary_sensor[bedrock_mock_config_entry-BedrockServer-status_response1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor[java_mock_config_entry-JavaServer-status_response0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_update[bedrock_mock_config_entry-BedrockServer-status_response1] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_update[java_mock_config_entry-JavaServer-status_response0] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'connectivity', + 'friendly_name': 'Minecraft Server Status', + 'icon': 'mdi:lan', + }), + 'context': , + 'entity_id': 'binary_sensor.minecraft_server_status', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- \ No newline at end of file diff --git a/tests/components/minecraft_server/test_binary_sensor.py b/tests/components/minecraft_server/test_binary_sensor.py new file mode 100644 index 00000000000..9fae35b113d --- /dev/null +++ b/tests/components/minecraft_server/test_binary_sensor.py @@ -0,0 +1,128 @@ +"""Tests for Minecraft Server binary sensor.""" +from datetime import timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from mcstatus import BedrockServer, JavaServer +from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.const import STATE_OFF +from homeassistant.core import HomeAssistant + +from .const import ( + TEST_BEDROCK_STATUS_RESPONSE, + TEST_HOST, + TEST_JAVA_STATUS_RESPONSE, + TEST_PORT, +) + +from tests.common import async_fire_time_changed + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, +) -> None: + """Test binary sensor.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor_update( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + snapshot: SnapshotAssertion, + freezer: FrozenDateTimeFactory, +) -> None: + """Test binary sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.minecraft_server_status") == snapshot + + +@pytest.mark.parametrize( + ("mock_config_entry", "server", "status_response"), + [ + ("java_mock_config_entry", JavaServer, TEST_JAVA_STATUS_RESPONSE), + ("bedrock_mock_config_entry", BedrockServer, TEST_BEDROCK_STATUS_RESPONSE), + ], +) +async def test_binary_sensor_update_failure( + hass: HomeAssistant, + mock_config_entry: str, + server: JavaServer | BedrockServer, + status_response: JavaStatusResponse | BedrockStatusResponse, + request: pytest.FixtureRequest, + freezer: FrozenDateTimeFactory, +) -> None: + """Test failed binary sensor update.""" + mock_config_entry = request.getfixturevalue(mock_config_entry) + mock_config_entry.add_to_hass(hass) + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.lookup", + return_value=server(host=TEST_HOST, port=TEST_PORT), + ), patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + return_value=status_response, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + with patch( + f"homeassistant.components.minecraft_server.api.{server.__name__}.async_status", + side_effect=OSError, + ): + freezer.tick(timedelta(minutes=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.minecraft_server_status").state == STATE_OFF + ) From 3896ed47be792875ac0a68b0ee0c1f0af8eeed8f Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 21 Oct 2023 16:57:11 +0200 Subject: [PATCH 649/968] Fix switches list for Comelit SmartHome (#102336) * fix switches list * make entities a single list * fix duplicate ids * move comment to a better position --- homeassistant/components/comelit/cover.py | 2 +- homeassistant/components/comelit/light.py | 2 +- homeassistant/components/comelit/switch.py | 16 ++++++++++------ 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 8bccd12e9a5..61b0cb39061 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -26,7 +26,6 @@ async def async_setup_entry( coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available async_add_entities( ComelitCoverEntity(coordinator, device, config_entry.entry_id) for device in coordinator.data[COVER].values() @@ -52,6 +51,7 @@ class ComelitCoverEntity( self._api = coordinator.api self._device = device super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) # Device doesn't provide a status so we assume UNKNOWN at first startup diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 15c4ec8cc7e..258dc2ce1e7 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -25,7 +25,6 @@ async def async_setup_entry( coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available async_add_entities( ComelitLightEntity(coordinator, device, config_entry.entry_id) for device in coordinator.data[LIGHT].values() @@ -48,6 +47,7 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 89271f142f5..46cbb74fce7 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -25,13 +25,16 @@ async def async_setup_entry( coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available - async_add_entities( + entities: list[ComelitSwitchEntity] = [] + entities.extend( ComelitSwitchEntity(coordinator, device, config_entry.entry_id) - for device in ( - coordinator.data[OTHER].values() + coordinator.data[IRRIGATION].values() - ) + for device in coordinator.data[IRRIGATION].values() ) + entities.extend( + ComelitSwitchEntity(coordinator, device, config_entry.entry_id) + for device in coordinator.data[OTHER].values() + ) + async_add_entities(entities) class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): @@ -50,7 +53,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) - self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) if device.type == OTHER: self._attr_device_class = SwitchDeviceClass.OUTLET From e5b58589155ff83ea3554a75eb2fbc4313d96654 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 21 Oct 2023 17:39:54 +0200 Subject: [PATCH 650/968] Bump aiowithings to 0.5.0 (#102456) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index 0b89df4af7b..b2c04467f37 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==0.4.4"] + "requirements": ["aiowithings==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ba07ce23a2f..d5e215b5971 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==0.4.4 +aiowithings==0.5.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39fa2b67f6f..67bd7352a46 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==0.4.4 +aiowithings==0.5.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 From bd0df2f18f29292a80be14cf3fa38c8416af51d7 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sat, 21 Oct 2023 17:53:32 +0200 Subject: [PATCH 651/968] Add energy price number entities to Wallbox (#101840) --- .../components/wallbox/coordinator.py | 15 +++ homeassistant/components/wallbox/number.py | 59 +++++++-- homeassistant/components/wallbox/strings.json | 3 + tests/components/wallbox/__init__.py | 45 ++++++- tests/components/wallbox/const.py | 1 + tests/components/wallbox/test_number.py | 121 +++++++++++++++++- 6 files changed, 227 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/wallbox/coordinator.py b/homeassistant/components/wallbox/coordinator.py index fe8dd2469c3..b9248d8ce5b 100644 --- a/homeassistant/components/wallbox/coordinator.py +++ b/homeassistant/components/wallbox/coordinator.py @@ -150,6 +150,21 @@ class WallboxCoordinator(DataUpdateCoordinator[dict[str, Any]]): ) await self.async_request_refresh() + def _set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + try: + self._authenticate() + self._wallbox.setEnergyCost(self._station, energy_cost) + except requests.exceptions.HTTPError as wallbox_connection_error: + if wallbox_connection_error.response.status_code == 403: + raise InvalidAuth from wallbox_connection_error + raise ConnectionError from wallbox_connection_error + + async def async_set_energy_cost(self, energy_cost: float) -> None: + """Set energy cost for Wallbox.""" + await self.hass.async_add_executor_job(self._set_energy_cost, energy_cost) + await self.async_request_refresh() + def _set_lock_unlock(self, lock: bool) -> None: """Set wallbox to locked or unlocked.""" try: diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 13938626336..9694e13103c 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -4,6 +4,7 @@ The number component allows control of charging current. """ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import cast @@ -16,6 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( BIDIRECTIONAL_MODEL_PREFIXES, CHARGER_DATA_KEY, + CHARGER_ENERGY_PRICE_KEY, CHARGER_MAX_AVAILABLE_POWER_KEY, CHARGER_MAX_CHARGING_CURRENT_KEY, CHARGER_PART_NUMBER_KEY, @@ -26,8 +28,29 @@ from .coordinator import InvalidAuth, WallboxCoordinator from .entity import WallboxEntity +def min_charging_current_value(coordinator: WallboxCoordinator) -> float: + """Return the minimum available value for charging current.""" + if ( + coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] + in BIDIRECTIONAL_MODEL_PREFIXES + ): + return cast(float, (coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] * -1)) + return 0 + + @dataclass -class WallboxNumberEntityDescription(NumberEntityDescription): +class WallboxNumberEntityDescriptionMixin: + """Load entities from different handlers.""" + + max_value_fn: Callable[[WallboxCoordinator], float] + min_value_fn: Callable[[WallboxCoordinator], float] + set_value_fn: Callable[[WallboxCoordinator], Callable[[float], Awaitable[None]]] + + +@dataclass +class WallboxNumberEntityDescription( + NumberEntityDescription, WallboxNumberEntityDescriptionMixin +): """Describes Wallbox number entity.""" @@ -35,6 +58,20 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, translation_key="maximum_charging_current", + max_value_fn=lambda coordinator: cast( + float, coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY] + ), + min_value_fn=min_charging_current_value, + set_value_fn=lambda coordinator: coordinator.async_set_charging_current, + native_step=1, + ), + CHARGER_ENERGY_PRICE_KEY: WallboxNumberEntityDescription( + key=CHARGER_ENERGY_PRICE_KEY, + translation_key="energy_price", + max_value_fn=lambda _: 5, + min_value_fn=lambda _: -5, + set_value_fn=lambda coordinator: coordinator.async_set_energy_cost, + native_step=0.01, ), } @@ -44,7 +81,7 @@ async def async_setup_entry( ) -> None: """Create wallbox number entities in HASS.""" coordinator: WallboxCoordinator = hass.data[DOMAIN][entry.entry_id] - # Check if the user is authorized to change current, if so, add number component: + # Check if the user has sufficient rights to change values, if so, add number component: try: await coordinator.async_set_charging_current( coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] @@ -79,28 +116,22 @@ class WallboxNumber(WallboxEntity, NumberEntity): self.entity_description = description self._coordinator = coordinator self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" - self._is_bidirectional = ( - coordinator.data[CHARGER_DATA_KEY][CHARGER_PART_NUMBER_KEY][0:2] - in BIDIRECTIONAL_MODEL_PREFIXES - ) @property def native_max_value(self) -> float: - """Return the maximum available current.""" - return cast(float, self._coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]) + """Return the maximum available value.""" + return self.entity_description.max_value_fn(self.coordinator) @property def native_min_value(self) -> float: - """Return the minimum available current based on charger type - some chargers can discharge.""" - return (self.max_value * -1) if self._is_bidirectional else 6 + """Return the minimum available value.""" + return self.entity_description.min_value_fn(self.coordinator) @property def native_value(self) -> float | None: """Return the value of the entity.""" - return cast( - float | None, self._coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] - ) + return cast(float | None, self._coordinator.data[self.entity_description.key]) async def async_set_native_value(self, value: float) -> None: """Set the value of the entity.""" - await self._coordinator.async_set_charging_current(value) + await self.entity_description.set_value_fn(self.coordinator)(value) diff --git a/homeassistant/components/wallbox/strings.json b/homeassistant/components/wallbox/strings.json index 69db4bb97e3..dd96cebf605 100644 --- a/homeassistant/components/wallbox/strings.json +++ b/homeassistant/components/wallbox/strings.json @@ -35,6 +35,9 @@ "number": { "maximum_charging_current": { "name": "Maximum charging current" + }, + "energy_price": { + "name": "Energy price" } }, "sensor": { diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index b995a066c51..d9bf9cfceaf 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -34,7 +34,7 @@ test_response = json.loads( { CHARGER_CHARGING_POWER_KEY: 0, CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.2, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, CHARGER_CHARGING_SPEED_KEY: 0, CHARGER_ADDED_RANGE_KEY: 150, CHARGER_ADDED_ENERGY_KEY: 44.697, @@ -52,6 +52,26 @@ test_response = json.loads( ) ) +test_response_bidir = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "QSP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + }, +} + + authorisation_response = json.loads( json.dumps( { @@ -109,6 +129,29 @@ async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None await hass.async_block_till_done() +async def setup_integration_bidir(hass: HomeAssistant, entry: MockConfigEntry) -> None: + """Test wallbox sensor class setup.""" + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=HTTPStatus.OK, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response_bidir, + status_code=HTTPStatus.OK, + ) + mock_request.put( + "https://api.wall-box.com/v2/charger/12345", + json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), + status_code=HTTPStatus.OK, + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + async def setup_integration_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: diff --git a/tests/components/wallbox/const.py b/tests/components/wallbox/const.py index 477fb10d292..4480b1ea7a4 100644 --- a/tests/components/wallbox/const.py +++ b/tests/components/wallbox/const.py @@ -6,6 +6,7 @@ ERROR = "error" STATUS = "status" MOCK_NUMBER_ENTITY_ID = "number.wallbox_wallboxname_maximum_charging_current" +MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID = "number.wallbox_wallboxname_energy_price" MOCK_LOCK_ENTITY_ID = "lock.wallbox_wallboxname_lock" MOCK_SENSOR_CHARGING_SPEED_ID = "sensor.wallbox_wallboxname_charging_speed" MOCK_SENSOR_CHARGING_POWER_ID = "sensor.wallbox_wallboxname_charging_power" diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 9d1663bf002..41ebedc91da 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -5,16 +5,21 @@ import pytest import requests_mock from homeassistant.components.input_number import ATTR_VALUE, SERVICE_SET_VALUE -from homeassistant.components.wallbox.const import CHARGER_MAX_CHARGING_CURRENT_KEY +from homeassistant.components.wallbox.const import ( + CHARGER_ENERGY_PRICE_KEY, + CHARGER_MAX_CHARGING_CURRENT_KEY, +) +from homeassistant.components.wallbox.coordinator import InvalidAuth from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from . import ( authorisation_response, setup_integration, + setup_integration_bidir, setup_integration_platform_not_ready, ) -from .const import MOCK_NUMBER_ENTITY_ID +from .const import MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, MOCK_NUMBER_ENTITY_ID from tests.common import MockConfigEntry @@ -37,6 +42,9 @@ async def test_wallbox_number_class( json=json.loads(json.dumps({CHARGER_MAX_CHARGING_CURRENT_KEY: 20})), status_code=200, ) + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == 0 + assert state.attributes["max"] == 25 await hass.services.async_call( "number", @@ -50,6 +58,51 @@ async def test_wallbox_number_class( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_bidir( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration_bidir(hass, entry) + + state = hass.states.get(MOCK_NUMBER_ENTITY_ID) + assert state.attributes["min"] == -25 + assert state.attributes["max"] == 25 + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_energy_class( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=200, + ) + + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_connection_error( hass: HomeAssistant, entry: MockConfigEntry ) -> None: @@ -82,6 +135,70 @@ async def test_wallbox_number_class_connection_error( await hass.config_entries.async_unload(entry.entry_id) +async def test_wallbox_number_class_energy_price_connection_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=404, + ) + + with pytest.raises(ConnectionError): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_wallbox_number_class_energy_price_auth_error( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test wallbox sensor class.""" + + await setup_integration(hass, entry) + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=200, + ) + mock_request.post( + "https://api.wall-box.com/chargers/config/12345", + json=json.loads(json.dumps({CHARGER_ENERGY_PRICE_KEY: 1.1})), + status_code=403, + ) + + with pytest.raises(InvalidAuth): + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: MOCK_NUMBER_ENTITY_ENERGY_PRICE_ID, + ATTR_VALUE: 1.1, + }, + blocking=True, + ) + await hass.config_entries.async_unload(entry.entry_id) + + async def test_wallbox_number_class_platform_not_ready( hass: HomeAssistant, entry: MockConfigEntry ) -> None: From 864b69e586b999a962d0206c4f02df78cb777c40 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Sat, 21 Oct 2023 13:19:37 -0400 Subject: [PATCH 652/968] Downgrade ZHA dependency bellows (#102471) Downgrade bellows --- 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 c97eb608960..dc8f06882e2 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.7", + "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.105", diff --git a/requirements_all.txt b/requirements_all.txt index d5e215b5971..d6a47cc551d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.7 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67bd7352a46..73e52fb8c40 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.7 +bellows==0.36.5 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 From c36166f01d695dbfb9d1975fc970625cb07c05b0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sat, 21 Oct 2023 19:44:56 +0200 Subject: [PATCH 653/968] Add sensor platform to Comelit SmartHome (#102465) * Add sensor platform to Comelit SmartHome * apply review comments --- .coveragerc | 1 + homeassistant/components/comelit/__init__.py | 2 +- homeassistant/components/comelit/sensor.py | 81 ++++++++++++++++++++ 3 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/comelit/sensor.py diff --git a/.coveragerc b/.coveragerc index 92bc1b47ad1..be0afb5d21f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -178,6 +178,7 @@ omit = homeassistant/components/comelit/cover.py homeassistant/components/comelit/coordinator.py homeassistant/components/comelit/light.py + homeassistant/components/comelit/sensor.py homeassistant/components/comelit/switch.py homeassistant/components/comfoconnect/fan.py homeassistant/components/concord232/alarm_control_panel.py diff --git a/homeassistant/components/comelit/__init__.py b/homeassistant/components/comelit/__init__.py index c279bcd08f3..b271644234d 100644 --- a/homeassistant/components/comelit/__init__.py +++ b/homeassistant/components/comelit/__init__.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from .const import DEFAULT_PORT, DOMAIN from .coordinator import ComelitSerialBridge -PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SWITCH] +PLATFORMS = [Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py new file mode 100644 index 00000000000..5cbc708d63e --- /dev/null +++ b/homeassistant/components/comelit/sensor.py @@ -0,0 +1,81 @@ +"""Support for sensors.""" +from __future__ import annotations + +from typing import Final + +from aiocomelit import ComelitSerialBridgeObject +from aiocomelit.const import OTHER + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import ComelitSerialBridge + +SENSOR_TYPES: Final = ( + SensorEntityDescription( + key="power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Comelit sensors.""" + + coordinator: ComelitSerialBridge = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[ComelitSensorEntity] = [] + for device in coordinator.data[OTHER].values(): + entities.extend( + ComelitSensorEntity(coordinator, device, config_entry.entry_id, sensor_desc) + for sensor_desc in SENSOR_TYPES + ) + + async_add_entities(entities) + + +class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): + """Sensor device.""" + + _attr_has_entity_name = True + entity_description: SensorEntityDescription + + def __init__( + self, + coordinator: ComelitSerialBridge, + device: ComelitSerialBridgeObject, + config_entry_entry_id: str, + description: SensorEntityDescription, + ) -> None: + """Init sensor entity.""" + self._api = coordinator.api + self._device = device + super().__init__(coordinator) + # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" + self._attr_device_info = coordinator.platform_device_info(device) + + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Sensor value.""" + return getattr( + self.coordinator.data[OTHER][self._device.index], + self.entity_description.key, + ) From e8146e5565dd82e14d05af474b361fff1ed70812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20N=C3=B6thlich?= Date: Sat, 21 Oct 2023 21:03:24 +0200 Subject: [PATCH 654/968] Add support for Bosch QR-codes for zha.permit (#102427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adrian Nöthlich --- homeassistant/components/zha/core/helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index cb9fadad00b..0246c1e4b1c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -397,6 +397,15 @@ QR_CODES = ( ([0-9a-fA-F]{36}) # install code $ """, + # Bosch + r""" + ^RB01SG + [0-9a-fA-F]{34} + ([0-9a-fA-F]{16}) # IEEE address + DLK + ([0-9a-fA-F]{36}) # install code + $ + """, ) From f0f3a43b09f5cadea430844de01f22401097785e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Oct 2023 12:40:33 -0700 Subject: [PATCH 655/968] Bump ical to 5.1.0 (#102483) --- homeassistant/components/local_calendar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/local_calendar/manifest.json b/homeassistant/components/local_calendar/manifest.json index acc2ac80caa..ac95c6b0f0e 100644 --- a/homeassistant/components/local_calendar/manifest.json +++ b/homeassistant/components/local_calendar/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/local_calendar", "iot_class": "local_polling", "loggers": ["ical"], - "requirements": ["ical==5.0.1"] + "requirements": ["ical==5.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6a47cc551d..b529e8d2ed3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1045,7 +1045,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar -ical==5.0.1 +ical==5.1.0 # homeassistant.components.ping icmplib==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73e52fb8c40..8e978a73d60 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -825,7 +825,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar -ical==5.0.1 +ical==5.1.0 # homeassistant.components.ping icmplib==3.0 From 235a3486ee6c1b7d2a6d3547dbdcdb3940a56646 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 21 Oct 2023 21:51:37 +0200 Subject: [PATCH 656/968] Add sensors for Withings Goals (#102468) --- homeassistant/components/withings/__init__.py | 3 + homeassistant/components/withings/const.py | 1 + .../components/withings/coordinator.py | 15 + homeassistant/components/withings/sensor.py | 105 +- .../components/withings/strings.json | 9 + tests/components/withings/conftest.py | 6 +- tests/components/withings/fixtures/goals.json | 8 + .../components/withings/fixtures/goals_1.json | 6 + .../withings/snapshots/test_sensor.ambr | 924 +++++++++--------- tests/components/withings/test_sensor.py | 32 +- 10 files changed, 664 insertions(+), 445 deletions(-) create mode 100644 tests/components/withings/fixtures/goals.json create mode 100644 tests/components/withings/fixtures/goals_1.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index ba58ee650be..ef91f3368a9 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -56,6 +56,7 @@ from .const import ( CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, + GOALS_COORDINATOR, LOGGER, MEASUREMENT_COORDINATOR, SLEEP_COORDINATOR, @@ -63,6 +64,7 @@ from .const import ( from .coordinator import ( WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, + WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, ) @@ -160,6 +162,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: BED_PRESENCE_COORDINATOR: WithingsBedPresenceDataUpdateCoordinator( hass, client ), + GOALS_COORDINATOR: WithingsGoalsDataUpdateCoordinator(hass, client), } for coordinator in coordinators.values(): diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 4eeaa56c76d..f04500bb3b8 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -16,6 +16,7 @@ PUSH_HANDLER = "push_handler" MEASUREMENT_COORDINATOR = "measurement_coordinator" SLEEP_COORDINATOR = "sleep_coordinator" BED_PRESENCE_COORDINATOR = "bed_presence_coordinator" +GOALS_COORDINATOR = "goals_coordinator" LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index ac320aae3ae..2700b833cea 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from typing import TypeVar from aiowithings import ( + Goals, MeasurementType, NotificationCategory, SleepSummary, @@ -170,3 +171,17 @@ class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[Non async def _internal_update_data(self) -> None: """Update coordinator data.""" + + +class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]): + """Withings goals coordinator.""" + + _default_update_interval = timedelta(hours=1) + + def webhook_subscription_listener(self, connected: bool) -> None: + """Call when webhook status changed.""" + # Webhooks aren't available for this datapoint, so we keep polling + + async def _internal_update_data(self) -> Goals: + """Retrieve goals data.""" + return await self._client.get_goals() diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index d09ae550d0f..54c13500e1d 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from aiowithings import MeasurementType, SleepSummary +from aiowithings import Goals, MeasurementType, SleepSummary from homeassistant.components.sensor import ( SensorDeviceClass, @@ -27,6 +27,7 @@ from homeassistant.helpers.typing import StateType from .const import ( DOMAIN, + GOALS_COORDINATOR, MEASUREMENT_COORDINATOR, SCORE_POINTS, SLEEP_COORDINATOR, @@ -37,6 +38,7 @@ from .const import ( ) from .coordinator import ( WithingsDataUpdateCoordinator, + WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, ) @@ -396,6 +398,64 @@ SLEEP_SENSORS = [ ] +STEP_GOAL = "steps" +SLEEP_GOAL = "sleep" +WEIGHT_GOAL = "weight" + + +@dataclass +class WithingsGoalsSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Goals], StateType] + + +@dataclass +class WithingsGoalsSensorEntityDescription( + SensorEntityDescription, WithingsGoalsSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { + STEP_GOAL: WithingsGoalsSensorEntityDescription( + key="step_goal", + value_fn=lambda goals: goals.steps, + icon="mdi:shoe-print", + translation_key="step_goal", + native_unit_of_measurement="Steps", + state_class=SensorStateClass.MEASUREMENT, + ), + SLEEP_GOAL: WithingsGoalsSensorEntityDescription( + key="sleep_goal", + value_fn=lambda goals: goals.sleep, + icon="mdi:bed-clock", + translation_key="sleep_goal", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + WEIGHT_GOAL: WithingsGoalsSensorEntityDescription( + key="weight_goal", + value_fn=lambda goals: goals.weight, + translation_key="weight_goal", + native_unit_of_measurement=UnitOfMass.KILOGRAMS, + device_class=SensorDeviceClass.WEIGHT, + state_class=SensorStateClass.MEASUREMENT, + ), +} + + +def get_current_goals(goals: Goals) -> set[str]: + """Return a list of present goals.""" + result = set() + for goal in (STEP_GOAL, SLEEP_GOAL, WEIGHT_GOAL): + if getattr(goals, goal): + result.add(goal) + return result + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -406,8 +466,6 @@ async def async_setup_entry( DOMAIN ][entry.entry_id][MEASUREMENT_COORDINATOR] - current_measurement_types = set(measurement_coordinator.data) - entities: list[SensorEntity] = [] entities.extend( WithingsMeasurementSensor( @@ -417,6 +475,8 @@ async def async_setup_entry( if measurement_type in MEASUREMENT_SENSORS ) + current_measurement_types = set(measurement_coordinator.data) + def _async_measurement_listener() -> None: """Listen for new measurements and add sensors if they did not exist.""" received_measurement_types = set(measurement_coordinator.data) @@ -431,6 +491,31 @@ async def async_setup_entry( ) measurement_coordinator.async_add_listener(_async_measurement_listener) + + goals_coordinator: WithingsGoalsDataUpdateCoordinator = hass.data[DOMAIN][ + entry.entry_id + ][GOALS_COORDINATOR] + + current_goals = get_current_goals(goals_coordinator.data) + + entities.extend( + WithingsGoalsSensor(goals_coordinator, GOALS_SENSORS[goal]) + for goal in current_goals + ) + + def _async_goals_listener() -> None: + """Listen for new goals and add sensors if they did not exist.""" + received_goals = get_current_goals(goals_coordinator.data) + new_goals = received_goals - current_goals + if new_goals: + current_goals.update(new_goals) + async_add_entities( + WithingsGoalsSensor(goals_coordinator, GOALS_SENSORS[goal]) + for goal in new_goals + ) + + goals_coordinator.async_add_listener(_async_goals_listener) + sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id ][SLEEP_COORDINATOR] @@ -492,3 +577,17 @@ class WithingsSleepSensor(WithingsSensor): def available(self) -> bool: """Return if the sensor is available.""" return super().available and self.coordinator.data is not None + + +class WithingsGoalsSensor(WithingsSensor): + """Implementation of a Withings goals sensor.""" + + coordinator: WithingsGoalsDataUpdateCoordinator + + entity_description: WithingsGoalsSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + assert self.coordinator.data + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 020509064b3..fcb94d6979a 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -134,6 +134,15 @@ }, "wakeup_time": { "name": "Wakeup time" + }, + "step_goal": { + "name": "Step goal" + }, + "sleep_goal": { + "name": "Sleep goal" + }, + "weight_goal": { + "name": "Weight goal" } } } diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 3f3a82a03f3..0131feba943 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -3,7 +3,7 @@ from datetime import timedelta import time from unittest.mock import AsyncMock, patch -from aiowithings import Device, MeasurementGroup, SleepSummary, WithingsClient +from aiowithings import Device, Goals, MeasurementGroup, SleepSummary, WithingsClient from aiowithings.models import NotificationConfiguration import pytest @@ -148,8 +148,12 @@ def mock_withings(): for not_conf in notification_json["profiles"] ] + goals_json = load_json_object_fixture("withings/goals.json") + goals = Goals.from_api(goals_json) + mock = AsyncMock(spec=WithingsClient) mock.get_devices.return_value = devices + mock.get_goals.return_value = goals mock.get_measurement_in_period.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups mock.get_sleep_summary_since.return_value = sleep_summaries diff --git a/tests/components/withings/fixtures/goals.json b/tests/components/withings/fixtures/goals.json new file mode 100644 index 00000000000..233ece9aac6 --- /dev/null +++ b/tests/components/withings/fixtures/goals.json @@ -0,0 +1,8 @@ +{ + "steps": 10000, + "sleep": 28800, + "weight": { + "value": 70500, + "unit": -3 + } +} diff --git a/tests/components/withings/fixtures/goals_1.json b/tests/components/withings/fixtures/goals_1.json new file mode 100644 index 00000000000..6b8046f0eb4 --- /dev/null +++ b/tests/components/withings/fixtures/goals_1.json @@ -0,0 +1,6 @@ +{ + "weight": { + "value": 70500, + "unit": -3 + } +} diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 6a0bee0fbc8..3546a24d2fe 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,255 +1,5 @@ # serializer version: 1 -# name: test_all_entities - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Weight', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_weight', - 'last_changed': , - 'last_updated': , - 'state': '70', - }) -# --- -# name: test_all_entities.1 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Fat mass', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_fat_mass', - 'last_changed': , - 'last_updated': , - 'state': '5', - }) -# --- -# name: test_all_entities.10 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Diastolic blood pressure', - 'state_class': , - 'unit_of_measurement': 'mmhg', - }), - 'context': , - 'entity_id': 'sensor.henk_diastolic_blood_pressure', - 'last_changed': , - 'last_updated': , - 'state': '70', - }) -# --- -# name: test_all_entities.11 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Systolic blood pressure', - 'state_class': , - 'unit_of_measurement': 'mmhg', - }), - 'context': , - 'entity_id': 'sensor.henk_systolic_blood_pressure', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.12 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Heart pulse', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.henk_heart_pulse', - 'last_changed': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities.13 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk SpO2', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.henk_spo2', - 'last_changed': , - 'last_updated': , - 'state': '0.95', - }) -# --- -# name: test_all_entities.14 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Hydration', - 'icon': 'mdi:water', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_hydration', - 'last_changed': , - 'last_updated': , - 'state': '0.95', - }) -# --- -# name: test_all_entities.15 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'speed', - 'friendly_name': 'henk Pulse wave velocity', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_pulse_wave_velocity', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.16 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk VO2 max', - 'state_class': , - 'unit_of_measurement': 'ml/min/kg', - }), - 'context': , - 'entity_id': 'sensor.henk_vo2_max', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.17 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Vascular age', - }), - 'context': , - 'entity_id': 'sensor.henk_vascular_age', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.18 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Extracellular water', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_extracellular_water', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.19 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Intracellular water', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_intracellular_water', - 'last_changed': , - 'last_updated': , - 'state': '100', - }) -# --- -# name: test_all_entities.2 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Fat free mass', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_fat_free_mass', - 'last_changed': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities.20 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Breathing disturbances intensity', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.henk_breathing_disturbances_intensity', - 'last_changed': , - 'last_updated': , - 'state': '10', - }) -# --- -# name: test_all_entities.21 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Deep sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_deep_sleep', - 'last_changed': , - 'last_updated': , - 'state': '26220', - }) -# --- -# name: test_all_entities.22 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_time_to_sleep', - 'last_changed': , - 'last_updated': , - 'state': '780', - }) -# --- -# name: test_all_entities.23 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Time to wakeup', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_time_to_wakeup', - 'last_changed': , - 'last_updated': , - 'state': '996', - }) -# --- -# name: test_all_entities.24 +# name: test_all_entities[sensor.henk_average_heart_rate] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average heart rate', @@ -264,69 +14,7 @@ 'state': '83', }) # --- -# name: test_all_entities.25 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum heart rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.henk_maximum_heart_rate', - 'last_changed': , - 'last_updated': , - 'state': '108', - }) -# --- -# name: test_all_entities.26 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Minimum heart rate', - 'icon': 'mdi:heart-pulse', - 'state_class': , - 'unit_of_measurement': 'bpm', - }), - 'context': , - 'entity_id': 'sensor.henk_minimum_heart_rate', - 'last_changed': , - 'last_updated': , - 'state': '58', - }) -# --- -# name: test_all_entities.27 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Light sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_light_sleep', - 'last_changed': , - 'last_updated': , - 'state': '58440', - }) -# --- -# name: test_all_entities.28 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk REM sleep', - 'icon': 'mdi:sleep', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_rem_sleep', - 'last_changed': , - 'last_updated': , - 'state': '17280', - }) -# --- -# name: test_all_entities.29 +# name: test_all_entities[sensor.henk_average_respiratory_rate] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'henk Average respiratory rate', @@ -340,122 +28,22 @@ 'state': '14', }) # --- -# name: test_all_entities.3 +# name: test_all_entities[sensor.henk_body_temperature] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'weight', - 'friendly_name': 'henk Muscle mass', + 'device_class': 'temperature', + 'friendly_name': 'henk Body temperature', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_muscle_mass', + 'entity_id': 'sensor.henk_body_temperature', 'last_changed': , 'last_updated': , - 'state': '50', + 'state': '40', }) # --- -# name: test_all_entities.30 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Maximum respiratory rate', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.henk_maximum_respiratory_rate', - 'last_changed': , - 'last_updated': , - 'state': '20', - }) -# --- -# name: test_all_entities.31 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Minimum respiratory rate', - 'state_class': , - 'unit_of_measurement': 'br/min', - }), - 'context': , - 'entity_id': 'sensor.henk_minimum_respiratory_rate', - 'last_changed': , - 'last_updated': , - 'state': '10', - }) -# --- -# name: test_all_entities.32 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Sleep score', - 'icon': 'mdi:medal', - 'state_class': , - 'unit_of_measurement': 'points', - }), - 'context': , - 'entity_id': 'sensor.henk_sleep_score', - 'last_changed': , - 'last_updated': , - 'state': '90', - }) -# --- -# name: test_all_entities.33 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Snoring', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.henk_snoring', - 'last_changed': , - 'last_updated': , - 'state': '1044', - }) -# --- -# name: test_all_entities.34 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Snoring episode count', - 'state_class': , - }), - 'context': , - 'entity_id': 'sensor.henk_snoring_episode_count', - 'last_changed': , - 'last_updated': , - 'state': '87', - }) -# --- -# name: test_all_entities.35 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Wakeup count', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': 'times', - }), - 'context': , - 'entity_id': 'sensor.henk_wakeup_count', - 'last_changed': , - 'last_updated': , - 'state': '8', - }) -# --- -# name: test_all_entities.36 - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'duration', - 'friendly_name': 'henk Wakeup time', - 'icon': 'mdi:sleep-off', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.henk_wakeup_time', - 'last_changed': , - 'last_updated': , - 'state': '3468', - }) -# --- -# name: test_all_entities.4 +# name: test_all_entities[sensor.henk_bone_mass] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'weight', @@ -471,7 +59,124 @@ 'state': '10', }) # --- -# name: test_all_entities.5 +# name: test_all_entities[sensor.henk_breathing_disturbances_intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[sensor.henk_deep_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Deep sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_deep_sleep', + 'last_changed': , + 'last_updated': , + 'state': '26220', + }) +# --- +# name: test_all_entities[sensor.henk_diastolic_blood_pressure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Diastolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_diastolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_all_entities[sensor.henk_extracellular_water] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Extracellular water', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_extracellular_water', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_fat_free_mass] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat free mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_free_mass', + 'last_changed': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[sensor.henk_fat_mass] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Fat mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass', + 'last_changed': , + 'last_updated': , + 'state': '5', + }) +# --- +# name: test_all_entities[sensor.henk_fat_ratio] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat ratio', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_ratio', + 'last_changed': , + 'last_updated': , + 'state': '0.07', + }) +# --- +# name: test_all_entities[sensor.henk_heart_pulse] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Heart pulse', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_heart_pulse', + 'last_changed': , + 'last_updated': , + 'state': '60', + }) +# --- +# name: test_all_entities[sensor.henk_height] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'distance', @@ -486,37 +191,158 @@ 'state': '2', }) # --- -# name: test_all_entities.6 +# name: test_all_entities[sensor.henk_hydration] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'henk Temperature', + 'device_class': 'weight', + 'friendly_name': 'henk Hydration', + 'icon': 'mdi:water', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_temperature', + 'entity_id': 'sensor.henk_hydration', 'last_changed': , 'last_updated': , - 'state': '40', + 'state': '0.95', }) # --- -# name: test_all_entities.7 +# name: test_all_entities[sensor.henk_intracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'henk Body temperature', + 'device_class': 'weight', + 'friendly_name': 'henk Intracellular water', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , - 'entity_id': 'sensor.henk_body_temperature', + 'entity_id': 'sensor.henk_intracellular_water', 'last_changed': , 'last_updated': , - 'state': '40', + 'state': '100', }) # --- -# name: test_all_entities.8 +# name: test_all_entities[sensor.henk_light_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Light sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_light_sleep', + 'last_changed': , + 'last_updated': , + 'state': '58440', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_heart_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '108', + }) +# --- +# name: test_all_entities[sensor.henk_maximum_respiratory_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Maximum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_maximum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_heart_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '58', + }) +# --- +# name: test_all_entities[sensor.henk_minimum_respiratory_rate] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Minimum respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_minimum_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '10', + }) +# --- +# name: test_all_entities[sensor.henk_muscle_mass] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Muscle mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_muscle_mass', + 'last_changed': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.henk_pulse_wave_velocity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'speed', + 'friendly_name': 'henk Pulse wave velocity', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pulse_wave_velocity', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_rem_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk REM sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_rem_sleep', + 'last_changed': , + 'last_updated': , + 'state': '17280', + }) +# --- +# name: test_all_entities[sensor.henk_skin_temperature] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'temperature', @@ -531,17 +357,237 @@ 'state': '20', }) # --- -# name: test_all_entities.9 +# name: test_all_entities[sensor.henk_sleep_goal] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'friendly_name': 'henk Fat ratio', + 'device_class': 'duration', + 'friendly_name': 'henk Sleep goal', + 'icon': 'mdi:bed-clock', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_sleep_goal', + 'last_changed': , + 'last_updated': , + 'state': '28800', + }) +# --- +# name: test_all_entities[sensor.henk_sleep_score] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Sleep score', + 'icon': 'mdi:medal', + 'state_class': , + 'unit_of_measurement': 'points', + }), + 'context': , + 'entity_id': 'sensor.henk_sleep_score', + 'last_changed': , + 'last_updated': , + 'state': '90', + }) +# --- +# name: test_all_entities[sensor.henk_snoring] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring', + 'last_changed': , + 'last_updated': , + 'state': '1044', + }) +# --- +# name: test_all_entities[sensor.henk_snoring_episode_count] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring episode count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring_episode_count', + 'last_changed': , + 'last_updated': , + 'state': '87', + }) +# --- +# name: test_all_entities[sensor.henk_spo2] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk SpO2', 'state_class': , 'unit_of_measurement': '%', }), 'context': , - 'entity_id': 'sensor.henk_fat_ratio', + 'entity_id': 'sensor.henk_spo2', 'last_changed': , 'last_updated': , - 'state': '0.07', + 'state': '0.95', + }) +# --- +# name: test_all_entities[sensor.henk_step_goal] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Step goal', + 'icon': 'mdi:shoe-print', + 'state_class': , + 'unit_of_measurement': 'Steps', + }), + 'context': , + 'entity_id': 'sensor.henk_step_goal', + 'last_changed': , + 'last_updated': , + 'state': '10000', + }) +# --- +# name: test_all_entities[sensor.henk_systolic_blood_pressure] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Systolic blood pressure', + 'state_class': , + 'unit_of_measurement': 'mmhg', + }), + 'context': , + 'entity_id': 'sensor.henk_systolic_blood_pressure', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_temperature] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40', + }) +# --- +# name: test_all_entities[sensor.henk_time_to_sleep] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to sleep', + 'icon': 'mdi:sleep', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_sleep', + 'last_changed': , + 'last_updated': , + 'state': '780', + }) +# --- +# name: test_all_entities[sensor.henk_time_to_wakeup] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Time to wakeup', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_time_to_wakeup', + 'last_changed': , + 'last_updated': , + 'state': '996', + }) +# --- +# name: test_all_entities[sensor.henk_vascular_age] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Vascular age', + }), + 'context': , + 'entity_id': 'sensor.henk_vascular_age', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_vo2_max] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk VO2 max', + 'state_class': , + 'unit_of_measurement': 'ml/min/kg', + }), + 'context': , + 'entity_id': 'sensor.henk_vo2_max', + 'last_changed': , + 'last_updated': , + 'state': '100', + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_count] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Wakeup count', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': 'times', + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_count', + 'last_changed': , + 'last_updated': , + 'state': '8', + }) +# --- +# name: test_all_entities[sensor.henk_wakeup_time] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Wakeup time', + 'icon': 'mdi:sleep-off', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_wakeup_time', + 'last_changed': , + 'last_updated': , + 'state': '3468', + }) +# --- +# name: test_all_entities[sensor.henk_weight] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Weight', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_weight', + 'last_changed': , + 'last_updated': , + 'state': '70', + }) +# --- +# name: test_all_entities[sensor.henk_weight_goal] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'weight', + 'friendly_name': 'henk Weight goal', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_weight_goal', + 'last_changed': , + 'last_updated': , + 'state': '70.5', }) # --- diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 2e5be3e74aa..6738d9a3eb4 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from aiowithings import MeasurementGroup +from aiowithings import Goals, MeasurementGroup from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -38,7 +38,9 @@ async def test_all_entities( assert entity_entries for entity_entry in entity_entries: - assert hass.states.get(entity_entry.entity_id) == snapshot + assert hass.states.get(entity_entry.entity_id) == snapshot( + name=entity_entry.entity_id + ) async def test_update_failed( @@ -135,3 +137,29 @@ async def test_update_new_measurement_creates_new_sensor( await hass.async_block_till_done() assert hass.states.get("sensor.henk_fat_mass") is not None + + +async def test_update_new_goals_creates_new_sensor( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test fetching new goals will add a new sensor.""" + goals_json = load_json_object_fixture("withings/goals_1.json") + goals = Goals.from_api(goals_json) + withings.get_goals.return_value = goals + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_step_goal") is None + assert hass.states.get("sensor.henk_weight_goal") is not None + + goals_json = load_json_object_fixture("withings/goals.json") + goals = Goals.from_api(goals_json) + withings.get_goals.return_value = goals + + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_step_goal") is not None From 017c699e1980818c4fa05140076fa7cbb91cad6a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 21 Oct 2023 22:06:12 +0200 Subject: [PATCH 657/968] Let the statistics component calculate changes in fossil energy consumption calculation (#101557) --- .../components/energy/websocket_api.py | 22 +-- tests/components/energy/test_websocket_api.py | 139 +++++++++++++++++- 2 files changed, 137 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/energy/websocket_api.py b/homeassistant/components/energy/websocket_api.py index 7830d3649f2..a4ee4d0d15f 100644 --- a/homeassistant/components/energy/websocket_api.py +++ b/homeassistant/components/energy/websocket_api.py @@ -274,10 +274,10 @@ async def ws_get_fossil_energy_consumption( statistic_ids, "hour", {"energy": UnitOfEnergy.KILO_WATT_HOUR}, - {"mean", "sum"}, + {"mean", "change"}, ) - def _combine_sum_statistics( + def _combine_change_statistics( stats: dict[str, list[StatisticsRow]], statistic_ids: list[str] ) -> dict[float, float]: """Combine multiple statistics, returns a dict indexed by start time.""" @@ -287,21 +287,12 @@ async def ws_get_fossil_energy_consumption( if statistics_id not in statistic_ids: continue for period in stat: - if period["sum"] is None: + if period["change"] is None: continue - result[period["start"]] += period["sum"] + result[period["start"]] += period["change"] return {key: result[key] for key in sorted(result)} - def _calculate_deltas(sums: dict[float, float]) -> dict[float, float]: - prev: float | None = None - result: dict[float, float] = {} - for period, sum_ in sums.items(): - if prev is not None: - result[period] = sum_ - prev - prev = sum_ - return result - def _reduce_deltas( stat_list: list[dict[str, Any]], same_period: Callable[[float, float], bool], @@ -334,10 +325,9 @@ async def ws_get_fossil_energy_consumption( return result - merged_energy_statistics = _combine_sum_statistics( + merged_energy_statistics = _combine_change_statistics( statistics, msg["energy_statistic_ids"] ) - energy_deltas = _calculate_deltas(merged_energy_statistics) indexed_co2_statistics = cast( dict[float, float], { @@ -349,7 +339,7 @@ async def ws_get_fossil_energy_consumption( # Calculate amount of fossil based energy, assume 100% fossil if missing fossil_energy = [ {"start": start, "delta": delta * indexed_co2_statistics.get(start, 100) / 100} - for start, delta in energy_deltas.items() + for start, delta in merged_energy_statistics.items() ] if msg["period"] == "hour": diff --git a/tests/components/energy/test_websocket_api.py b/tests/components/energy/test_websocket_api.py index d045fedd4b3..f953d0e3a03 100644 --- a/tests/components/energy/test_websocket_api.py +++ b/tests/components/energy/test_websocket_api.py @@ -423,6 +423,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(22.0), period2.isoformat(): pytest.approx(33.0 - 22.0), period3.isoformat(): pytest.approx(55.0 - 33.0), period4.isoformat(): pytest.approx(88.0 - 55.0), @@ -445,6 +446,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(22.0), period2_day_start.isoformat(): pytest.approx(33.0 - 22.0), period3.isoformat(): pytest.approx(55.0 - 33.0), period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), @@ -467,7 +469,7 @@ async def test_fossil_energy_consumption_no_co2( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx(33.0 - 22.0), + period1.isoformat(): pytest.approx(33.0), period3.isoformat(): pytest.approx((55.0 - 33.0) + (88.0 - 55.0)), } @@ -586,8 +588,9 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period2.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx(55.0 - 3.0), + period1.isoformat(): pytest.approx(20.0), + period2.isoformat(): pytest.approx(3.0), + period3.isoformat(): pytest.approx(32.0), period4.isoformat(): pytest.approx(88.0 - 55.0), } @@ -608,8 +611,9 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period2_day_start.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx(55.0 - 3.0), + period1.isoformat(): pytest.approx(20.0), + period2_day_start.isoformat(): pytest.approx(3.0), + period3.isoformat(): pytest.approx(32.0), period4_day_start.isoformat(): pytest.approx(88.0 - 55.0), } @@ -630,8 +634,8 @@ async def test_fossil_energy_consumption_hole( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx(3.0 - 20.0), - period3.isoformat(): pytest.approx((55.0 - 3.0) + (88.0 - 55.0)), + period1.isoformat(): pytest.approx(23.0), + period3.isoformat(): pytest.approx((55.0 - 3.0) + (88.0 - 55.0) - 20.0), } @@ -930,6 +934,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(11.0 * 0.2), period2.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), period4.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), @@ -952,6 +957,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { + period1.isoformat(): pytest.approx(11.0 * 0.2), period2_day_start.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), period3.isoformat(): pytest.approx((44.0 - 33.0) * 0.6), period4_day_start.isoformat(): pytest.approx((55.0 - 44.0) * 0.9), @@ -974,7 +980,7 @@ async def test_fossil_energy_consumption( response = await client.receive_json() assert response["success"] assert response["result"] == { - period1.isoformat(): pytest.approx((33.0 - 22.0) * 0.3), + period1.isoformat(): pytest.approx(11.0 * 0.5), period3.isoformat(): pytest.approx( ((44.0 - 33.0) * 0.6) + ((55.0 - 44.0) * 0.9) ), @@ -1032,3 +1038,120 @@ async def test_fossil_energy_consumption_checks( assert msg["id"] == 2 assert not msg["success"] assert msg["error"] == {"code": "invalid_end_time", "message": "Invalid end_time"} + + +@pytest.mark.freeze_time("2021-08-01 01:00:00+00:00") +async def test_fossil_energy_consumption_check_missing_hour( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test explicitly if the API keeps the first hour of data for the requested time frame.""" + + now = dt_util.utcnow() + later = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 05:00:00")) + + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + hour1 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 01:00:00")) + hour2 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 02:00:00")) + hour3 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 03:00:00")) + hour4 = dt_util.as_utc(dt_util.parse_datetime("2021-08-01 04:00:00")) + + # add energy statistics for 4 hours + energy_statistics_1 = ( + { + "start": hour1, + "last_reset": None, + "state": 0, + "sum": 1, + }, + { + "start": hour2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": hour3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": hour4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + energy_metadata_1 = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, energy_metadata_1, energy_statistics_1) + + # add co2 statistics for 4 hours + co2_statistics = ( + { + "start": hour1, + "last_reset": None, + "mean": 10, + }, + { + "start": hour2, + "last_reset": None, + "mean": 30, + }, + { + "start": hour3, + "last_reset": None, + "mean": 60, + }, + { + "start": hour4, + "last_reset": None, + "mean": 90, + }, + ) + co2_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Fossil percentage", + "source": "test", + "statistic_id": "test:fossil_percentage", + "unit_of_measurement": "%", + } + + async_add_external_statistics(hass, co2_metadata, co2_statistics) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "energy/fossil_energy_consumption", + "start_time": now.isoformat(), + "end_time": later.isoformat(), + "energy_statistic_ids": [ + "test:total_energy_import", + ], + "co2_statistic_id": "test:fossil_percentage", + "period": "hour", + } + ) + + # check if we received deltas for the requested time frame + response = await client.receive_json() + assert response["success"] + assert list(response["result"].keys()) == [ + hour1.isoformat(), + hour2.isoformat(), + hour3.isoformat(), + hour4.isoformat(), + ] From f626f3bc1e19e39ecf38bd04fba28c2713445951 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 10:06:28 -1000 Subject: [PATCH 658/968] Bump aiohomekit to 3.0.8 (#102479) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 3299bde21d3..ff918396640 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.7"], + "requirements": ["aiohomekit==3.0.8"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index b529e8d2ed3..77dad968fbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.7 +aiohomekit==3.0.8 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e978a73d60..34146e31428 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.7 +aiohomekit==3.0.8 # homeassistant.components.emulated_hue # homeassistant.components.http From 89f9d64bf53f81d79a03f8f70a48d7f5523268ea Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 10:06:39 -1000 Subject: [PATCH 659/968] Add early return check to passive Bluetooth entities listener (#102435) --- homeassistant/components/bluetooth/passive_update_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 7294d55f912..39338e94a77 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -470,7 +470,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): data: PassiveBluetoothDataUpdate[_T] | None, ) -> None: """Listen for new entities.""" - if data is None: + if data is None or created.issuperset(data.entity_descriptions): return entities: list[PassiveBluetoothProcessorEntity] = [] for entity_key, description in data.entity_descriptions.items(): From f9dbddc88446e24639ef5b4dab44335dc6d7a9f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 10:06:49 -1000 Subject: [PATCH 660/968] Small cleanups to Bluetooth fallback intervals (#102440) --- .../components/bluetooth/advertisement_tracker.py | 9 ++++++++- homeassistant/components/bluetooth/manager.py | 10 ++++++---- tests/components/bluetooth/test_diagnostics.py | 3 +++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bluetooth/advertisement_tracker.py b/homeassistant/components/bluetooth/advertisement_tracker.py index b6a70e32865..f17bcf938f5 100644 --- a/homeassistant/components/bluetooth/advertisement_tracker.py +++ b/homeassistant/components/bluetooth/advertisement_tracker.py @@ -18,11 +18,12 @@ TRACKER_BUFFERING_WOBBLE_SECONDS = 5 class AdvertisementTracker: """Tracker to determine the interval that a device is advertising.""" - __slots__ = ("intervals", "sources", "_timings") + __slots__ = ("intervals", "fallback_intervals", "sources", "_timings") def __init__(self) -> None: """Initialize the tracker.""" self.intervals: dict[str, float] = {} + self.fallback_intervals: dict[str, float] = {} self.sources: dict[str, str] = {} self._timings: dict[str, list[float]] = {} @@ -31,6 +32,7 @@ class AdvertisementTracker: """Return diagnostics.""" return { "intervals": self.intervals, + "fallback_intervals": self.fallback_intervals, "sources": self.sources, "timings": self._timings, } @@ -67,6 +69,11 @@ class AdvertisementTracker: self.sources.pop(address, None) self._timings.pop(address, None) + @callback + def async_remove_fallback_interval(self, address: str) -> None: + """Remove fallback interval.""" + self.fallback_intervals.pop(address, None) + @callback def async_remove_source(self, source: str) -> None: """Remove the tracker.""" diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index d69558fe7fd..34edccaf4ab 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -109,6 +109,7 @@ class BluetoothManager: "_cancel_logging_listener", "_advertisement_tracker", "_fallback_intervals", + "_intervals", "_unavailable_callbacks", "_connectable_unavailable_callbacks", "_callback_index", @@ -140,7 +141,8 @@ class BluetoothManager: self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() - self._fallback_intervals: dict[str, float] = {} + self._fallback_intervals = self._advertisement_tracker.fallback_intervals + self._intervals = self._advertisement_tracker.intervals self._unavailable_callbacks: dict[ str, list[Callable[[BluetoothServiceInfoBleak], None]] @@ -359,7 +361,7 @@ class BluetoothManager: # The second loop (connectable=False) is responsible for removing # the device from all the interval tracking since it is no longer # available for both connectable and non-connectable - self._fallback_intervals.pop(address, None) + tracker.async_remove_fallback_interval(address) tracker.async_remove_address(address) self._integration_matcher.async_clear_address(address) self._async_dismiss_discoveries(address) @@ -390,7 +392,7 @@ class BluetoothManager: ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( - stale_seconds := self._advertisement_tracker.intervals.get( + stale_seconds := self._intervals.get( new.address, self._fallback_intervals.get( new.address, FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS @@ -791,7 +793,7 @@ class BluetoothManager: @hass_callback def async_get_learned_advertising_interval(self, address: str) -> float | None: """Get the learned advertising interval for a MAC address.""" - return self._advertisement_tracker.intervals.get(address) + return self._intervals.get(address) @hass_callback def async_get_fallback_availability_interval(self, address: str) -> float | None: diff --git a/tests/components/bluetooth/test_diagnostics.py b/tests/components/bluetooth/test_diagnostics.py index 765e2a9a612..0e8b2b54f06 100644 --- a/tests/components/bluetooth/test_diagnostics.py +++ b/tests/components/bluetooth/test_diagnostics.py @@ -157,6 +157,7 @@ async def test_diagnostics( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {}, "timings": {}, }, @@ -328,6 +329,7 @@ async def test_diagnostics_macos( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {"44:44:33:11:23:45": "local"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, @@ -520,6 +522,7 @@ async def test_diagnostics_remote_adapter( }, "advertisement_tracker": { "intervals": {}, + "fallback_intervals": {}, "sources": {"44:44:33:11:23:45": "esp32"}, "timings": {"44:44:33:11:23:45": [ANY]}, }, From 51596c6231394c2ac52f5096dc7ca34abeaeab36 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 10:07:02 -1000 Subject: [PATCH 661/968] Remove useless freezing on PassiveBluetoothDataUpdate (#102434) --- homeassistant/components/bluetooth/passive_update_processor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 39338e94a77..b4ac15fc28c 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -119,7 +119,7 @@ def serialize_entity_description(description: EntityDescription) -> dict[str, An } -@dataclasses.dataclass(slots=True, frozen=True) +@dataclasses.dataclass(slots=True, frozen=False) class PassiveBluetoothDataUpdate(Generic[_T]): """Generic bluetooth data.""" From aa9301be32060a9cc93e4d71ecdb02a6782bf7cb Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sat, 21 Oct 2023 22:09:09 +0200 Subject: [PATCH 662/968] Bump async-upnp-client to 0.36.2 (#102472) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index e269d75e0f6..0fa884319c4 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", "iot_class": "local_push", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 0d07eb0c042..b3fa91a2e70 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/dlna_dms", "iot_class": "local_polling", "quality_scale": "platinum", - "requirements": ["async-upnp-client==0.36.1"], + "requirements": ["async-upnp-client==0.36.2"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index a3f35b65555..48bdb7083b4 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -39,7 +39,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", - "async-upnp-client==0.36.1" + "async-upnp-client==0.36.2" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 21f0036aabd..bf48b44e5dc 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_push", "loggers": ["async_upnp_client"], "quality_scale": "internal", - "requirements": ["async-upnp-client==0.36.1"] + "requirements": ["async-upnp-client==0.36.2"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 1651dea6612..25f83e0dbf5 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -8,7 +8,7 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["async_upnp_client"], - "requirements": ["async-upnp-client==0.36.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.36.2", "getmac==0.8.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1" diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index ecb8c1f35d2..6c44736fa6d 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -17,7 +17,7 @@ "iot_class": "local_push", "loggers": ["async_upnp_client", "yeelight"], "quality_scale": "platinum", - "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.1"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.36.2"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c3761418217..cc6be3705e1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiohttp==3.8.5;python_version<'3.12' aiohttp==3.9.0b0;python_version>='3.12' aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.36.1 +async-upnp-client==0.36.2 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 diff --git a/requirements_all.txt b/requirements_all.txt index 77dad968fbc..152a794df8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -467,7 +467,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.1 +async-upnp-client==0.36.2 # homeassistant.components.keyboard_remote asyncinotify==4.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34146e31428..07556c85898 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -421,7 +421,7 @@ async-interrupt==1.1.1 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.36.1 +async-upnp-client==0.36.2 # homeassistant.components.sleepiq asyncsleepiq==1.3.7 From f4d91043fcc1ed15bda10181519cc8ae4fd59fee Mon Sep 17 00:00:00 2001 From: Xitee <59659167+Xitee1@users.noreply.github.com> Date: Sat, 21 Oct 2023 23:05:04 +0200 Subject: [PATCH 663/968] Add codeowner for roomba (#102492) --- CODEOWNERS | 4 ++-- homeassistant/components/roomba/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6d2637c1f3e..ff485ff6d92 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1065,8 +1065,8 @@ build.json @home-assistant/supervisor /tests/components/roborock/ @humbertogontijo @Lash-L /homeassistant/components/roku/ @ctalkington /tests/components/roku/ @ctalkington -/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn -/tests/components/roomba/ @pschmitt @cyr-ius @shenxn +/homeassistant/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 +/tests/components/roomba/ @pschmitt @cyr-ius @shenxn @Xitee1 /homeassistant/components/roon/ @pavoni /tests/components/roon/ @pavoni /homeassistant/components/rpi_power/ @shenxn @swetoast diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 9e18465922a..8e6b92732eb 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -1,7 +1,7 @@ { "domain": "roomba", "name": "iRobot Roomba and Braava", - "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn"], + "codeowners": ["@pschmitt", "@cyr-ius", "@shenxn", "@Xitee1"], "config_flow": true, "dhcp": [ { From 242124504b5857323dac2d8ac5a1abc5bb95b02e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 22 Oct 2023 00:21:55 +0200 Subject: [PATCH 664/968] Improve mqtt config issue string constants (#102496) Improve config issue string constants --- homeassistant/components/mqtt/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 9082e034e02..68fa39bfdc9 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -21,8 +21,8 @@ "description": "Entity `{entity_id}` has auxiliary heat support enabled, which has been deprecated for MQTT climate devices. Please adjust your configuration and remove deprecated config options from your configuration and restart Home Assistant to fix this issue." }, "invalid_platform_config": { - "title": "Invalid configured MQTT {domain} item", - "description": "Home Assistant detected an invalid config for a manual configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." + "title": "Invalid config found for mqtt {domain} item", + "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." } }, "config": { From 0ebc97ad85fec02062d897550ef8138a8a1c6606 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 14:30:53 -1000 Subject: [PATCH 665/968] Bump yalexs-ble to 2.3.1 (#102502) --- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/yalexs_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 2fe7d62ac3d..50df1f4bd1d 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.10.0", "yalexs-ble==2.3.1"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index cbff581d296..8d15fbb9a9f 100644 --- a/homeassistant/components/yalexs_ble/manifest.json +++ b/homeassistant/components/yalexs_ble/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/yalexs_ble", "iot_class": "local_push", - "requirements": ["yalexs-ble==2.3.0"] + "requirements": ["yalexs-ble==2.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 152a794df8f..99eadd7aa6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2756,7 +2756,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.0 +yalexs-ble==2.3.1 # homeassistant.components.august yalexs==1.10.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 07556c85898..91bafe647b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2056,7 +2056,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.3.0 +yalexs-ble==2.3.1 # homeassistant.components.august yalexs==1.10.0 From 6f2245bba32b5fddb5c7819300d64797727c7e75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 16:51:12 -1000 Subject: [PATCH 666/968] Bump aioesphomeapi to 18.0.8 (#102493) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 8db47d83cad..cf8b2405786 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.7", + "aioesphomeapi==18.0.8", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 99eadd7aa6a..ce28527d1bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.7 +aioesphomeapi==18.0.8 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91bafe647b0..eb4ad91e4d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.7 +aioesphomeapi==18.0.8 # homeassistant.components.flo aioflo==2021.11.0 From 1801a7738c70f0c8447c39c6d4c3c121b6e4f591 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 04:51:21 +0200 Subject: [PATCH 667/968] Bump aiowithings to 1.0.0 (#102499) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index b2c04467f37..dbee20ca32b 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==0.5.0"] + "requirements": ["aiowithings==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ce28527d1bf..8f46743740c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==0.5.0 +aiowithings==1.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eb4ad91e4d6..b646810dd6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==0.5.0 +aiowithings==1.0.0 # homeassistant.components.yandex_transport aioymaps==1.2.2 From c4f562ff6afbd8f5f968e3d2cff8abd6a080ab48 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 21 Oct 2023 22:00:40 -0700 Subject: [PATCH 668/968] Reduce unnecessary fitbit RPCs on startup (#102504) * Reduce unnecessary fitbit RPCs on startup * Update comment about racing user profile rpcs --- homeassistant/components/fitbit/sensor.py | 19 +++++--- tests/components/fitbit/test_sensor.py | 54 +++++++++++++++++++---- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 17bd21544e0..45b8ea21b0e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -1,7 +1,6 @@ """Support for the Fitbit API.""" from __future__ import annotations -import asyncio from collections.abc import Callable from dataclasses import dataclass import datetime @@ -620,10 +619,10 @@ async def async_setup_entry( data: FitbitData = hass.data[DOMAIN][entry.entry_id] api = data.api - # Note: This will only be one rpc since it will cache the user profile - (user_profile, unit_system) = await asyncio.gather( - api.async_get_user_profile(), api.async_get_unit_system() - ) + # These are run serially to reuse the cached user profile, not gathered + # to avoid two racing requests. + user_profile = await api.async_get_user_profile() + unit_system = await api.async_get_unit_system() fitbit_config = config_from_entry_data(entry.data) @@ -654,7 +653,7 @@ async def async_setup_entry( for description in resource_list if is_allowed_resource(description) ] - async_add_entities(entities, True) + async_add_entities(entities) if data.device_coordinator and is_allowed_resource(FITBIT_RESOURCE_BATTERY): async_add_entities( @@ -712,6 +711,14 @@ class FitbitSensor(SensorEntity): self._attr_available = True self._attr_native_value = self.entity_description.value_fn(result) + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + + # We do not ask for an update with async_add_entities() + # because it will update disabled entities. + self.async_schedule_update_ha_state(force_refresh=True) + class FitbitBatterySensor(CoordinatorEntity, SensorEntity): """Implementation of a Fitbit sensor.""" diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index 926b599dfb5..b54f154d406 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -538,15 +538,8 @@ async def test_settings_scope_config_entry( integration_setup: Callable[[], Awaitable[bool]], register_timeseries: Callable[[str, dict[str, Any]], None], ) -> None: - """Test heartrate sensors are enabled.""" + """Test device sensors are enabled.""" - for api_resource in ("activities/heart",): - register_timeseries( - api_resource, - timeseries_response( - api_resource.replace("/", "-"), {"restingHeartRate": "0"} - ), - ) assert await integration_setup() states = hass.states.async_all() @@ -617,6 +610,51 @@ async def test_sensor_update_failed_requires_reauth( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize( + ("scopes"), + [(["heartrate"])], +) +async def test_sensor_update_success( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + requests_mock: Mocker, +) -> None: + """Test API failure for a battery level sensor for devices.""" + + requests_mock.register_uri( + "GET", + TIMESERIES_API_URL_FORMAT.format(resource="activities/heart"), + [ + { + "status_code": HTTPStatus.OK, + "json": timeseries_response( + "activities-heart", {"restingHeartRate": "60"} + ), + }, + { + "status_code": HTTPStatus.OK, + "json": timeseries_response( + "activities-heart", {"restingHeartRate": "70"} + ), + }, + ], + ) + + assert await integration_setup() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "60" + + await async_update_entity(hass, "sensor.resting_heart_rate") + await hass.async_block_till_done() + + state = hass.states.get("sensor.resting_heart_rate") + assert state + assert state.state == "70" + + @pytest.mark.parametrize( ("scopes", "mock_devices"), [(["settings"], None)], From 215febc912092afa0058641492fdebd272fe4508 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 21 Oct 2023 20:17:22 -1000 Subject: [PATCH 669/968] Bump aioesphomeapi to 18.0.9 (#102509) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index cf8b2405786..f59a09b3d6b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.8", + "aioesphomeapi==18.0.9", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 8f46743740c..52cbd9e83ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.8 +aioesphomeapi==18.0.9 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b646810dd6a..de3ed6ee87b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.8 +aioesphomeapi==18.0.9 # homeassistant.components.flo aioflo==2021.11.0 From 973b8900a9f31eacbdc3eaaafb70c3f50e02427a Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 22 Oct 2023 08:31:08 +0200 Subject: [PATCH 670/968] Optimize mqtt platform setup (#102449) * Optimize mqtt platform setup and correct issue * Avoid coroutine for setup entity from discovery * Avoid extra check * Revert string constants * Add comment --- homeassistant/components/mqtt/__init__.py | 11 ++------- homeassistant/components/mqtt/mixins.py | 27 +++++++++++++++-------- homeassistant/components/mqtt/models.py | 4 +--- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index abf4cc65dea..1f8e5bbf2e7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -432,15 +432,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] await asyncio.gather(*tasks) - await asyncio.gather( - *( - [ - mqtt_data.reload_handlers[component]() - for component in RELOADABLE_PLATFORMS - if component in mqtt_data.reload_handlers - ] - ) - ) + for _, component in mqtt_data.reload_handlers.items(): + component() # Fire event hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 767a012d179..908e3c768b8 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -29,7 +29,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -276,10 +276,14 @@ def async_handle_schema_error( async def _async_discover( hass: HomeAssistant, domain: str, - async_setup: partial[Coroutine[Any, Any, None]], + setup: partial[CALLBACK_TYPE] | None, + async_setup: partial[Coroutine[Any, Any, None]] | None, discovery_payload: MQTTDiscoveryPayload, ) -> None: - """Discover and add an MQTT entity, automation or tag.""" + """Discover and add an MQTT entity, automation or tag. + + setup is to be run in the event loop when there is nothing to be awaited. + """ if not mqtt_config_entry_enabled(hass): _LOGGER.warning( ( @@ -292,7 +296,10 @@ async def _async_discover( return discovery_data = discovery_payload.discovery_data try: - await async_setup(discovery_payload) + if setup is not None: + setup(discovery_payload) + elif async_setup is not None: + await async_setup(discovery_payload) except vol.Invalid as err: discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] clear_discovery_hash(hass, discovery_hash) @@ -326,7 +333,7 @@ async def async_setup_non_entity_entry_helper( hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), functools.partial( - _async_discover, hass, domain, async_setup_from_discovery + _async_discover, hass, domain, None, async_setup_from_discovery ), ) ) @@ -345,7 +352,8 @@ async def async_setup_entity_entry_helper( """Set up entity creation dynamically through MQTT discovery.""" mqtt_data = get_mqtt_data(hass) - async def async_setup_from_discovery( + @callback + def async_setup_from_discovery( discovery_payload: MQTTDiscoveryPayload, ) -> None: """Set up an MQTT entity from discovery.""" @@ -364,12 +372,13 @@ async def async_setup_entity_entry_helper( hass, MQTT_DISCOVERY_NEW.format(domain, "mqtt"), functools.partial( - _async_discover, hass, domain, async_setup_from_discovery + _async_discover, hass, domain, async_setup_from_discovery, None ), ) ) - async def _async_setup_entities() -> None: + @callback + def _async_setup_entities() -> None: """Set up MQTT items from configuration.yaml.""" nonlocal entity_class mqtt_data = get_mqtt_data(hass) @@ -433,7 +442,7 @@ async def async_setup_entity_entry_helper( mqtt_data.reload_schema[domain] = platform_schema_modern # discover manual configured MQTT items mqtt_data.reload_handlers[domain] = _async_setup_entities - await _async_setup_entities() + _async_setup_entities() def init_entity_id_from_config( diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 53442d35cef..2da2527ad7b 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -342,9 +342,7 @@ class MqttData: issues: dict[str, set[str]] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) - reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( - default_factory=dict - ) + reload_handlers: dict[str, CALLBACK_TYPE] = field(default_factory=dict) reload_schema: dict[str, vol.Schema] = field(default_factory=dict) state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) From 62d8472757799bac55e382822b8e203faac8ed2e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 22 Oct 2023 10:12:59 +0200 Subject: [PATCH 671/968] Move ping classes to their own module (#102448) --- .coveragerc | 1 + .../components/ping/binary_sensor.py | 156 +---------------- homeassistant/components/ping/helpers.py | 162 ++++++++++++++++++ 3 files changed, 166 insertions(+), 153 deletions(-) create mode 100644 homeassistant/components/ping/helpers.py diff --git a/.coveragerc b/.coveragerc index be0afb5d21f..b994e61a122 100644 --- a/.coveragerc +++ b/.coveragerc @@ -956,6 +956,7 @@ omit = homeassistant/components/ping/__init__.py homeassistant/components/ping/binary_sensor.py homeassistant/components/ping/device_tracker.py + homeassistant/components/ping/helpers.py homeassistant/components/pioneer/media_player.py homeassistant/components/plaato/__init__.py homeassistant/components/plaato/binary_sensor.py diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py index bab7f3a3735..b120c453195 100644 --- a/homeassistant/components/ping/binary_sensor.py +++ b/homeassistant/components/ping/binary_sensor.py @@ -1,14 +1,10 @@ """Tracks the latency of a host by sending ICMP echo requests (ping).""" from __future__ import annotations -import asyncio -from contextlib import suppress from datetime import timedelta import logging -import re -from typing import TYPE_CHECKING, Any +from typing import Any -from icmplib import NameLookupError, async_ping import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -24,7 +20,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import PingDomainData -from .const import DOMAIN, ICMP_TIMEOUT, PING_TIMEOUT +from .const import DOMAIN +from .helpers import PingDataICMPLib, PingDataSubProcess _LOGGER = logging.getLogger(__name__) @@ -43,16 +40,6 @@ SCAN_INTERVAL = timedelta(minutes=5) PARALLEL_UPDATES = 50 -PING_MATCHER = re.compile( - r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" -) - -PING_MATCHER_BUSYBOX = re.compile( - r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" -) - -WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") - PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -142,140 +129,3 @@ class PingBinarySensor(RestoreEntity, BinarySensorEntity): "avg": attributes[ATTR_ROUND_TRIP_TIME_AVG], "mdev": attributes[ATTR_ROUND_TRIP_TIME_MDEV], } - - -class PingData: - """The base class for handling the data retrieval.""" - - def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: - """Initialize the data object.""" - self.hass = hass - self._ip_address = host - self._count = count - self.data: dict[str, Any] | None = None - self.is_alive = False - - -class PingDataICMPLib(PingData): - """The Class for handling the data retrieval using icmplib.""" - - def __init__( - self, hass: HomeAssistant, host: str, count: int, privileged: bool | None - ) -> None: - """Initialize the data object.""" - super().__init__(hass, host, count) - self._privileged = privileged - - async def async_update(self) -> None: - """Retrieve the latest details from the host.""" - _LOGGER.debug("ping address: %s", self._ip_address) - try: - data = await async_ping( - self._ip_address, - count=self._count, - timeout=ICMP_TIMEOUT, - privileged=self._privileged, - ) - except NameLookupError: - self.is_alive = False - return - - self.is_alive = data.is_alive - if not self.is_alive: - self.data = None - return - - self.data = { - "min": data.min_rtt, - "max": data.max_rtt, - "avg": data.avg_rtt, - "mdev": "", - } - - -class PingDataSubProcess(PingData): - """The Class for handling the data retrieval using the ping binary.""" - - def __init__( - self, hass: HomeAssistant, host: str, count: int, privileged: bool | None - ) -> None: - """Initialize the data object.""" - super().__init__(hass, host, count) - self._ping_cmd = [ - "ping", - "-n", - "-q", - "-c", - str(self._count), - "-W1", - self._ip_address, - ] - - async def async_ping(self) -> dict[str, Any] | None: - """Send ICMP echo request and return details if success.""" - pinger = await asyncio.create_subprocess_exec( - *self._ping_cmd, - stdin=None, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - close_fds=False, # required for posix_spawn - ) - try: - async with asyncio.timeout(self._count + PING_TIMEOUT): - out_data, out_error = await pinger.communicate() - - if out_data: - _LOGGER.debug( - "Output of command: `%s`, return code: %s:\n%s", - " ".join(self._ping_cmd), - pinger.returncode, - out_data, - ) - if out_error: - _LOGGER.debug( - "Error of command: `%s`, return code: %s:\n%s", - " ".join(self._ping_cmd), - pinger.returncode, - out_error, - ) - - if pinger.returncode and pinger.returncode > 1: - # returncode of 1 means the host is unreachable - _LOGGER.exception( - "Error running command: `%s`, return code: %s", - " ".join(self._ping_cmd), - pinger.returncode, - ) - - if "max/" not in str(out_data): - match = PING_MATCHER_BUSYBOX.search( - str(out_data).rsplit("\n", maxsplit=1)[-1] - ) - if TYPE_CHECKING: - assert match is not None - rtt_min, rtt_avg, rtt_max = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} - match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) - if TYPE_CHECKING: - assert match is not None - rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() - return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} - except asyncio.TimeoutError: - _LOGGER.exception( - "Timed out running command: `%s`, after: %ss", - self._ping_cmd, - self._count + PING_TIMEOUT, - ) - if pinger: - with suppress(TypeError): - await pinger.kill() # type: ignore[func-returns-value] - del pinger - - return None - except AttributeError: - return None - - async def async_update(self) -> None: - """Retrieve the latest details from the host.""" - self.data = await self.async_ping() - self.is_alive = self.data is not None diff --git a/homeassistant/components/ping/helpers.py b/homeassistant/components/ping/helpers.py new file mode 100644 index 00000000000..da58858a801 --- /dev/null +++ b/homeassistant/components/ping/helpers.py @@ -0,0 +1,162 @@ +"""Ping classes shared between platforms.""" +import asyncio +from contextlib import suppress +import logging +import re +from typing import TYPE_CHECKING, Any + +from icmplib import NameLookupError, async_ping + +from homeassistant.core import HomeAssistant + +from .const import ICMP_TIMEOUT, PING_TIMEOUT + +_LOGGER = logging.getLogger(__name__) + +PING_MATCHER = re.compile( + r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" +) + +PING_MATCHER_BUSYBOX = re.compile( + r"(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)" +) + +WIN32_PING_MATCHER = re.compile(r"(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms") + + +class PingData: + """The base class for handling the data retrieval.""" + + data: dict[str, Any] | None = None + is_alive: bool = False + + def __init__(self, hass: HomeAssistant, host: str, count: int) -> None: + """Initialize the data object.""" + self.hass = hass + self._ip_address = host + self._count = count + + +class PingDataICMPLib(PingData): + """The Class for handling the data retrieval using icmplib.""" + + def __init__( + self, hass: HomeAssistant, host: str, count: int, privileged: bool | None + ) -> None: + """Initialize the data object.""" + super().__init__(hass, host, count) + self._privileged = privileged + + async def async_update(self) -> None: + """Retrieve the latest details from the host.""" + _LOGGER.debug("ping address: %s", self._ip_address) + try: + data = await async_ping( + self._ip_address, + count=self._count, + timeout=ICMP_TIMEOUT, + privileged=self._privileged, + ) + except NameLookupError: + self.is_alive = False + return + + self.is_alive = data.is_alive + if not self.is_alive: + self.data = None + return + + self.data = { + "min": data.min_rtt, + "max": data.max_rtt, + "avg": data.avg_rtt, + "mdev": "", + } + + +class PingDataSubProcess(PingData): + """The Class for handling the data retrieval using the ping binary.""" + + def __init__( + self, hass: HomeAssistant, host: str, count: int, privileged: bool | None + ) -> None: + """Initialize the data object.""" + super().__init__(hass, host, count) + self._ping_cmd = [ + "ping", + "-n", + "-q", + "-c", + str(self._count), + "-W1", + self._ip_address, + ] + + async def async_ping(self) -> dict[str, Any] | None: + """Send ICMP echo request and return details if success.""" + pinger = await asyncio.create_subprocess_exec( + *self._ping_cmd, + stdin=None, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + close_fds=False, # required for posix_spawn + ) + try: + async with asyncio.timeout(self._count + PING_TIMEOUT): + out_data, out_error = await pinger.communicate() + + if out_data: + _LOGGER.debug( + "Output of command: `%s`, return code: %s:\n%s", + " ".join(self._ping_cmd), + pinger.returncode, + out_data, + ) + if out_error: + _LOGGER.debug( + "Error of command: `%s`, return code: %s:\n%s", + " ".join(self._ping_cmd), + pinger.returncode, + out_error, + ) + + if pinger.returncode and pinger.returncode > 1: + # returncode of 1 means the host is unreachable + _LOGGER.exception( + "Error running command: `%s`, return code: %s", + " ".join(self._ping_cmd), + pinger.returncode, + ) + + if "max/" not in str(out_data): + match = PING_MATCHER_BUSYBOX.search( + str(out_data).rsplit("\n", maxsplit=1)[-1] + ) + if TYPE_CHECKING: + assert match is not None + rtt_min, rtt_avg, rtt_max = match.groups() + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": ""} + match = PING_MATCHER.search(str(out_data).rsplit("\n", maxsplit=1)[-1]) + if TYPE_CHECKING: + assert match is not None + rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups() + return {"min": rtt_min, "avg": rtt_avg, "max": rtt_max, "mdev": rtt_mdev} + except asyncio.TimeoutError: + _LOGGER.exception( + "Timed out running command: `%s`, after: %ss", + self._ping_cmd, + self._count + PING_TIMEOUT, + ) + if pinger: + with suppress(TypeError): + await pinger.kill() # type: ignore[func-returns-value] + del pinger + + return None + except AttributeError: + return None + + async def async_update(self) -> None: + """Retrieve the latest details from the host.""" + self.data = await self.async_ping() + self.is_alive = self.data is not None From 311e539c0ee3f67bfb4c6abebf0ca7da36718bee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 22 Oct 2023 13:57:38 +0200 Subject: [PATCH 672/968] Update aioairzone-cloud to v0.2.8 (#102515) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index b8992a80ee3..bc5bf1ee875 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.7"] + "requirements": ["aioairzone-cloud==0.2.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 52cbd9e83ea..4b730b6b0e7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.7 +aioairzone-cloud==0.2.8 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de3ed6ee87b..95cac4a6dc7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.7 +aioairzone-cloud==0.2.8 # homeassistant.components.airzone aioairzone==0.6.9 From b3bd34a024e91fed133d6249852e5506c6643ca2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 02:08:28 -1000 Subject: [PATCH 673/968] Avoid dispatching same state to passive bluetooth entities (#102430) --- .../bluetooth/passive_update_processor.py | 45 +++- .../test_passive_update_processor.py | 193 +++++++++++++++++- 2 files changed, 224 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index b4ac15fc28c..8138587b9b5 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -21,6 +21,7 @@ from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import UNDEFINED from homeassistant.util.enum import try_parse_enum from .const import DOMAIN @@ -134,12 +135,33 @@ class PassiveBluetoothDataUpdate(Generic[_T]): default_factory=dict ) - def update(self, new_data: PassiveBluetoothDataUpdate[_T]) -> None: - """Update the data.""" - self.devices.update(new_data.devices) - self.entity_descriptions.update(new_data.entity_descriptions) - self.entity_data.update(new_data.entity_data) - self.entity_names.update(new_data.entity_names) + def update( + self, new_data: PassiveBluetoothDataUpdate[_T] + ) -> set[PassiveBluetoothEntityKey] | None: + """Update the data and returned changed PassiveBluetoothEntityKey or None on device change. + + The changed PassiveBluetoothEntityKey can be used to filter + which listeners are called. + """ + device_change = False + changed_entity_keys: set[PassiveBluetoothEntityKey] = set() + for key, device_info in new_data.devices.items(): + if device_change or self.devices.get(key, UNDEFINED) != device_info: + device_change = True + self.devices[key] = device_info + for incoming, current in ( + (new_data.entity_descriptions, self.entity_descriptions), + (new_data.entity_names, self.entity_names), + (new_data.entity_data, self.entity_data), + ): + # mypy can't seem to work this out + for key, data in incoming.items(): # type: ignore[attr-defined] + if current.get(key, UNDEFINED) != data: # type: ignore[attr-defined] + changed_entity_keys.add(key) # type: ignore[arg-type] + current[key] = data # type: ignore[index] + # If the device changed we don't need to return the changed + # entity keys as all entities will be updated + return None if device_change else changed_entity_keys def async_get_restore_data(self) -> RestoredPassiveBluetoothDataUpdate: """Serialize restore data to storage.""" @@ -520,6 +542,7 @@ class PassiveBluetoothDataProcessor(Generic[_T]): self, data: PassiveBluetoothDataUpdate[_T] | None, was_available: bool | None = None, + changed_entity_keys: set[PassiveBluetoothEntityKey] | None = None, ) -> None: """Update all registered listeners.""" if was_available is None: @@ -542,6 +565,12 @@ class PassiveBluetoothDataProcessor(Generic[_T]): # if the key is in the data entity_key_listeners = self._entity_key_listeners for entity_key in data.entity_data: + if ( + was_available + and changed_entity_keys is not None + and entity_key not in changed_entity_keys + ): + continue if maybe_listener := entity_key_listeners.get(entity_key): for update_callback in maybe_listener: update_callback(data) @@ -573,8 +602,8 @@ class PassiveBluetoothDataProcessor(Generic[_T]): "Processing %s data recovered", self.coordinator.name ) - self.data.update(new_data) - self.async_update_listeners(new_data, was_available) + changed_entity_keys = self.data.update(new_data) + self.async_update_listeners(new_data, was_available, changed_entity_keys) class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 5baff65f29a..9e3f954a0c5 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -112,6 +112,65 @@ GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE = PassiveBluetoothDataUpdate( }, ) +GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE = PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Test Device", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 15.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("temperature", None): "Temperature", + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, +) + + +GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE = ( + PassiveBluetoothDataUpdate( + devices={ + None: DeviceInfo( + name="Changed", model="Test Model", manufacturer="Test Manufacturer" + ), + }, + entity_data={ + PassiveBluetoothEntityKey("temperature", None): 15.5, + PassiveBluetoothEntityKey("pressure", None): 1234, + }, + entity_names={ + PassiveBluetoothEntityKey("temperature", None): "Temperature", + PassiveBluetoothEntityKey("pressure", None): "Pressure", + }, + entity_descriptions={ + PassiveBluetoothEntityKey("temperature", None): SensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + ), + PassiveBluetoothEntityKey("pressure", None): SensorEntityDescription( + key="pressure", + native_unit_of_measurement="hPa", + device_class=SensorDeviceClass.PRESSURE, + ), + }, + ) +) + async def test_basic_usage( hass: HomeAssistant, @@ -189,9 +248,9 @@ async def test_basic_usage( inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) - # Each listener should receive the same data - # since both match - assert len(entity_key_events) == 2 + # Only the all listener should receive the new data + # since temperature is not in the new data + assert len(entity_key_events) == 1 assert len(all_events) == 2 # On the second, the entities should already be created @@ -206,8 +265,130 @@ async def test_basic_usage( # Each listener should not trigger any more now # that they were cancelled + assert len(entity_key_events) == 1 + assert len(all_events) == 2 + assert len(mock_entity.mock_calls) == 2 + assert coordinator.available is True + + unregister_processor() + cancel_coordinator() + + +async def test_entity_key_is_dispatched_on_entity_key_change( + hass: HomeAssistant, + mock_bleak_scanner_start: MagicMock, + mock_bluetooth_adapters: None, +) -> None: + """Test entity key listeners are only dispatched on change.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + update_count = 0 + + @callback + def _mock_update_method( + service_info: BluetoothServiceInfo, + ) -> dict[str, str]: + return {"test": "data"} + + @callback + def _async_generate_mock_data( + data: dict[str, str], + ) -> PassiveBluetoothDataUpdate: + """Generate mock data.""" + assert data == {"test": "data"} + nonlocal update_count + update_count += 1 + if update_count > 2: + return ( + GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_DEVICE_NAME_AND_TEMP_CHANGE + ) + if update_count > 1: + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE_WITH_TEMP_CHANGE + return GENERIC_PASSIVE_BLUETOOTH_DATA_UPDATE + + coordinator = PassiveBluetoothProcessorCoordinator( + hass, + _LOGGER, + "aa:bb:cc:dd:ee:ff", + BluetoothScanningMode.ACTIVE, + _mock_update_method, + ) + assert coordinator.available is False # no data yet + + processor = PassiveBluetoothDataProcessor(_async_generate_mock_data) + + unregister_processor = coordinator.async_register_processor(processor) + cancel_coordinator = coordinator.async_start() + + entity_key = PassiveBluetoothEntityKey("temperature", None) + entity_key_events = [] + all_events = [] + mock_entity = MagicMock() + mock_add_entities = MagicMock() + + def _async_entity_key_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock entity key listener.""" + entity_key_events.append(data) + + cancel_async_add_entity_key_listener = processor.async_add_entity_key_listener( + _async_entity_key_listener, + entity_key, + ) + + def _all_listener(data: PassiveBluetoothDataUpdate | None) -> None: + """Mock an all listener.""" + all_events.append(data) + + cancel_listener = processor.async_add_listener( + _all_listener, + ) + + cancel_async_add_entities_listener = processor.async_add_entities_listener( + mock_entity, + mock_add_entities, + ) + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # Each listener should receive the same data + # since both match + assert len(entity_key_events) == 1 + assert len(all_events) == 1 + + # There should be 4 calls to create entities + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Both listeners should receive the new data + # since temperature IS in the new data assert len(entity_key_events) == 2 assert len(all_events) == 2 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) + + # All listeners should receive the data since + # the device name changed + assert len(entity_key_events) == 3 + assert len(all_events) == 3 + + # On the second, the entities should already be created + # so the mock should not be called again + assert len(mock_entity.mock_calls) == 2 + + cancel_async_add_entity_key_listener() + cancel_listener() + cancel_async_add_entities_listener() + + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) + + # Each listener should not trigger any more now + # that they were cancelled + assert len(entity_key_events) == 3 + assert len(all_events) == 3 assert len(mock_entity.mock_calls) == 2 assert coordinator.available is True @@ -897,9 +1078,9 @@ async def test_integration_with_entity( # Forth call with both primary and remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 2 - # should not have triggered the entity key listener since there - # there is an update with the entity key - assert len(entity_key_events) == 3 + # should not have triggered the entity key listener humidity + # is not in the update + assert len(entity_key_events) == 2 entities = [ *mock_add_entities.mock_calls[0][1][0], From 1621310ba7f0c2bc750c54e3af52f07c84df9625 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 22 Oct 2023 15:14:44 +0200 Subject: [PATCH 674/968] Add serial_number to device registry entries (#102334) Co-authored-by: Joost Lekkerkerker --- homeassistant/helpers/device_registry.py | 17 +- .../components/config/test_device_registry.py | 3 + .../elgato/snapshots/test_button.ambr | 2 + .../elgato/snapshots/test_light.ambr | 3 + .../elgato/snapshots/test_sensor.ambr | 5 + .../elgato/snapshots/test_switch.ambr | 2 + .../energyzero/snapshots/test_sensor.ambr | 6 + .../snapshots/test_init.ambr | 1 + .../snapshots/test_init.ambr | 80 ++++++++ .../onewire/snapshots/test_binary_sensor.ambr | 21 +++ .../onewire/snapshots/test_sensor.ambr | 21 +++ .../onewire/snapshots/test_switch.ambr | 21 +++ .../renault/snapshots/test_binary_sensor.ambr | 8 + .../renault/snapshots/test_button.ambr | 8 + .../snapshots/test_device_tracker.ambr | 8 + .../renault/snapshots/test_select.ambr | 8 + .../renault/snapshots/test_sensor.ambr | 8 + .../sfr_box/snapshots/test_binary_sensor.ambr | 2 + .../sfr_box/snapshots/test_button.ambr | 1 + .../sfr_box/snapshots/test_sensor.ambr | 1 + .../twentemilieu/snapshots/test_calendar.ambr | 1 + .../twentemilieu/snapshots/test_sensor.ambr | 5 + .../uptime/snapshots/test_sensor.ambr | 2 + .../components/vesync/snapshots/test_fan.ambr | 9 + .../vesync/snapshots/test_light.ambr | 9 + .../vesync/snapshots/test_sensor.ambr | 9 + .../vesync/snapshots/test_switch.ambr | 9 + .../whois/snapshots/test_sensor.ambr | 9 + .../wled/snapshots/test_binary_sensor.ambr | 1 + .../wled/snapshots/test_button.ambr | 1 + .../wled/snapshots/test_number.ambr | 2 + .../wled/snapshots/test_select.ambr | 4 + .../wled/snapshots/test_switch.ambr | 4 + tests/helpers/test_device_registry.py | 175 +++++++++++++++--- 34 files changed, 444 insertions(+), 22 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 064579a95d3..48ebd7b6ebc 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -36,7 +36,7 @@ DATA_REGISTRY = "device_registry" EVENT_DEVICE_REGISTRY_UPDATED = "device_registry_updated" STORAGE_KEY = "core.device_registry" STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 3 +STORAGE_VERSION_MINOR = 4 SAVE_DELAY = 10 CLEANUP_DELAY = 10 @@ -79,6 +79,7 @@ class DeviceInfo(TypedDict, total=False): manufacturer: str | None model: str | None name: str | None + serial_number: str | None suggested_area: str | None sw_version: str | None hw_version: str | None @@ -102,6 +103,7 @@ DEVICE_INFO_TYPES = { "manufacturer", "model", "name", + "serial_number", "suggested_area", "sw_version", "via_device", @@ -229,6 +231,7 @@ class DeviceEntry: model: str | None = attr.ib(default=None) name_by_user: str | None = attr.ib(default=None) name: str | None = attr.ib(default=None) + serial_number: str | None = attr.ib(default=None) suggested_area: str | None = attr.ib(default=None) sw_version: str | None = attr.ib(default=None) via_device_id: str | None = attr.ib(default=None) @@ -257,6 +260,7 @@ class DeviceEntry: "model": self.model, "name_by_user": self.name_by_user, "name": self.name, + "serial_number": self.serial_number, "sw_version": self.sw_version, "via_device_id": self.via_device_id, } @@ -359,6 +363,10 @@ class DeviceRegistryStore(storage.Store[dict[str, list[dict[str, Any]]]]): # Version 1.3 adds hw_version for device in old_data["devices"]: device["hw_version"] = None + if old_minor_version < 4: + # Introduced in 2023.11 + for device in old_data["devices"]: + device["serial_number"] = None if old_major_version > 1: raise NotImplementedError @@ -490,6 +498,7 @@ class DeviceRegistry: manufacturer: str | None | UndefinedType = UNDEFINED, model: str | None | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, + serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device: tuple[str, str] | None | UndefinedType = UNDEFINED, @@ -514,6 +523,7 @@ class DeviceRegistry: ("manufacturer", manufacturer), ("model", model), ("name", name), + ("serial_number", serial_number), ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device", via_device), @@ -591,6 +601,7 @@ class DeviceRegistry: merge_identifiers=identifiers or UNDEFINED, model=model, name=name, + serial_number=serial_number, suggested_area=suggested_area, sw_version=sw_version, via_device_id=via_device_id, @@ -620,6 +631,7 @@ class DeviceRegistry: name: str | None | UndefinedType = UNDEFINED, new_identifiers: set[tuple[str, str]] | UndefinedType = UNDEFINED, remove_config_entry_id: str | UndefinedType = UNDEFINED, + serial_number: str | None | UndefinedType = UNDEFINED, suggested_area: str | None | UndefinedType = UNDEFINED, sw_version: str | None | UndefinedType = UNDEFINED, via_device_id: str | None | UndefinedType = UNDEFINED, @@ -709,6 +721,7 @@ class DeviceRegistry: ("model", model), ("name", name), ("name_by_user", name_by_user), + ("serial_number", serial_number), ("suggested_area", suggested_area), ("sw_version", sw_version), ("via_device_id", via_device_id), @@ -802,6 +815,7 @@ class DeviceRegistry: model=device["model"], name_by_user=device["name_by_user"], name=device["name"], + serial_number=device["serial_number"], sw_version=device["sw_version"], via_device_id=device["via_device_id"], ) @@ -851,6 +865,7 @@ class DeviceRegistry: "model": entry.model, "name_by_user": entry.name_by_user, "name": entry.name, + "serial_number": entry.serial_number, "sw_version": entry.sw_version, "via_device_id": entry.via_device_id, } diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index a92b2a353ef..87bb9cc9409 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -63,6 +63,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -79,6 +80,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": dev1, }, @@ -108,6 +110,7 @@ async def test_list_devices( "model": "model", "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, } diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index ed29c443243..134e213db6f 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -69,6 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -144,6 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index e9b3eec9a1b..f730015856d 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -101,6 +101,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -210,6 +211,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -319,6 +321,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 86a4c2e5cc5..3afcbc2e106 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -76,6 +76,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -161,6 +162,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -246,6 +248,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -328,6 +331,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -413,6 +417,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index cc841b338c7..ca34f8d0081 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -69,6 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -144,6 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/energyzero/snapshots/test_sensor.ambr b/tests/components/energyzero/snapshots/test_sensor.ambr index f3b5e66ed6c..00579ec7026 100644 --- a/tests/components/energyzero/snapshots/test_sensor.ambr +++ b/tests/components/energyzero/snapshots/test_sensor.ambr @@ -516,6 +516,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -584,6 +585,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -649,6 +651,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -715,6 +718,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -780,6 +784,7 @@ 'model': None, 'name': 'Energy market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -848,6 +853,7 @@ 'model': None, 'name': 'Gas market price', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/gardena_bluetooth/snapshots/test_init.ambr b/tests/components/gardena_bluetooth/snapshots/test_init.ambr index a3ecff80a46..ae0bb9ace09 100644 --- a/tests/components/gardena_bluetooth/snapshots/test_init.ambr +++ b/tests/components/gardena_bluetooth/snapshots/test_init.ambr @@ -21,6 +21,7 @@ 'model': 'Mock Model', 'name': 'Mock Title', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.3', 'via_device_id': None, diff --git a/tests/components/homekit_controller/snapshots/test_init.ambr b/tests/components/homekit_controller/snapshots/test_init.ambr index d37676e7edf..a0c6fd00ee6 100644 --- a/tests/components/homekit_controller/snapshots/test_init.ambr +++ b/tests/components/homekit_controller/snapshots/test_init.ambr @@ -24,6 +24,7 @@ 'model': 'AP2', 'name': 'Airversa AP2 1808', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.16', }), @@ -471,6 +472,7 @@ 'model': 'T8010', 'name': 'eufy HomeBase2-0AAA', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.1.6', }), @@ -536,6 +538,7 @@ 'model': 'T8113', 'name': 'eufyCam2-0000', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -754,6 +757,7 @@ 'model': 'T8113', 'name': 'eufyCam2-000A', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -972,6 +976,7 @@ 'model': 'T8113', 'name': 'eufyCam2-000A', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.6.7', }), @@ -1194,6 +1199,7 @@ 'model': 'HE1-G01', 'name': 'Aqara-Hub-E1-00A0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.3.0', }), @@ -1383,6 +1389,7 @@ 'model': 'AS006', 'name': 'Contact Sensor', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0', }), @@ -1531,6 +1538,7 @@ 'model': 'ZHWA11LM', 'name': 'Aqara Hub-1563', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.4.7', }), @@ -1773,6 +1781,7 @@ 'model': 'AR004', 'name': 'Programmable Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '9', }), @@ -1884,6 +1893,7 @@ 'model': 'ABC1000', 'name': 'ArloBabyA0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.10.931', }), @@ -2314,6 +2324,7 @@ 'model': 'CS-IWO', 'name': 'InWall Outlet-0394DE', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -2703,6 +2714,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Basement', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -2846,6 +2858,7 @@ 'model': 'ecobee3', 'name': 'HomeW', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.394', }), @@ -3261,6 +3274,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Kitchen', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -3404,6 +3418,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Porch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -3551,6 +3566,7 @@ 'model': 'ecobee3', 'name': 'HomeW', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.394', }), @@ -3970,6 +3986,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Basement', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -4072,6 +4089,7 @@ 'model': 'ecobee3', 'name': 'HomeW', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.394', }), @@ -4330,6 +4348,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Kitchen', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -4473,6 +4492,7 @@ 'model': 'REMOTE SENSOR', 'name': 'Porch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -4620,6 +4640,7 @@ 'model': 'ECB501', 'name': 'My ecobee', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.7.340214', }), @@ -5048,6 +5069,7 @@ 'model': 'ecobee Switch+', 'name': 'Master Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.5.130201', }), @@ -5309,6 +5331,7 @@ 'model': 'Eve Degree 00AAA0000', 'name': 'Eve Degree AA11', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.8', }), @@ -5635,6 +5658,7 @@ 'model': 'Eve Energy 20EAO8601', 'name': 'Eve Energy 50FF', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.2.9', }), @@ -5942,6 +5966,7 @@ 'model': 'RavenSystem HAA', 'name': 'HAA-C718B3', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '5.0.18', }), @@ -6124,6 +6149,7 @@ 'model': 'RavenSystem HAA', 'name': 'HAA-C718B3', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '5.0.18', }), @@ -6229,6 +6255,7 @@ 'model': 'Fan', 'name': 'Ceiling Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6337,6 +6364,7 @@ 'model': 'Bridge', 'name': 'Home Assistant Bridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6402,6 +6430,7 @@ 'model': 'Fan', 'name': 'Living Room Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6515,6 +6544,7 @@ 'model': 'Fan', 'name': 'Ceiling Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6623,6 +6653,7 @@ 'model': 'Bridge', 'name': 'Home Assistant Bridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6688,6 +6719,7 @@ 'model': 'Fan', 'name': 'Living Room Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6802,6 +6834,7 @@ 'model': 'Bridge', 'name': 'Home Assistant Bridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6867,6 +6900,7 @@ 'model': 'Fan', 'name': 'Living Room Fan', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.104.0.dev0', }), @@ -6981,6 +7015,7 @@ 'model': 'Daikin-fwec3a-esp32-homekit-bridge', 'name': 'Air Conditioner', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.0', }), @@ -7163,6 +7198,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7287,6 +7323,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7411,6 +7448,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7535,6 +7573,7 @@ 'model': 'LTW012', 'name': 'Hue ambiance candle', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7659,6 +7698,7 @@ 'model': 'LTW013', 'name': 'Hue ambiance spot', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7793,6 +7833,7 @@ 'model': 'LTW013', 'name': 'Hue ambiance spot', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -7927,6 +7968,7 @@ 'model': 'RWL021', 'name': 'Hue dimmer switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '45.1.17846', }), @@ -8214,6 +8256,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8325,6 +8368,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8436,6 +8480,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8547,6 +8592,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8658,6 +8704,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8769,6 +8816,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8880,6 +8928,7 @@ 'model': 'LWB010', 'name': 'Hue white lamp', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.46.13', }), @@ -8991,6 +9040,7 @@ 'model': 'BSB002', 'name': 'Philips hue - 482544', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.32.1932126170', }), @@ -9060,6 +9110,7 @@ 'model': 'LS1', 'name': 'Koogeek-LS1-20833F', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.2.15', }), @@ -9178,6 +9229,7 @@ 'model': 'P1EU', 'name': 'Koogeek-P1-A00AA0', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.3.7', }), @@ -9325,6 +9377,7 @@ 'model': 'KH02CN', 'name': 'Koogeek-SW2-187A91', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3', }), @@ -9507,6 +9560,7 @@ 'model': 'E30 2B', 'name': 'Lennox', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.40.XX', }), @@ -9764,6 +9818,7 @@ 'model': 'OLED55B9PUA', 'name': 'LG webOS TV AF80', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '04.71.04', }), @@ -9928,6 +9983,7 @@ 'model': 'PD-FSQN-XX', 'name': 'Caséta® Wireless Fan Speed Control', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '001.005', }), @@ -10036,6 +10092,7 @@ 'model': 'L-BDG2-WH', 'name': 'Smart Bridge 2', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '08.08', }), @@ -10105,6 +10162,7 @@ 'model': 'MSS425F', 'name': 'MSS425F-15cc', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.2.3', }), @@ -10354,6 +10412,7 @@ 'model': 'MSS565', 'name': 'MSS565-28da', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '4.1.9', }), @@ -10469,6 +10528,7 @@ 'model': 'v1', 'name': 'Mysa-85dda9', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '2.8.1', }), @@ -10770,6 +10830,7 @@ 'model': 'NL55', 'name': 'Nanoleaf Strip 3B32', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.4.40', }), @@ -11056,6 +11117,7 @@ 'model': 'Netatmo Doorbell', 'name': 'Netatmo-Doorbell-g738658', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '80.0.0', }), @@ -11322,6 +11384,7 @@ 'model': 'Smart CO Alarm', 'name': 'Smart CO Alarm', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.3', }), @@ -11465,6 +11528,7 @@ 'model': 'Healthy Home Coach', 'name': 'Healthy Home Coach', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '59', }), @@ -11738,6 +11802,7 @@ 'model': 'SPK5 Pro', 'name': 'RainMachine-00ce4a', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.0.4', }), @@ -12127,6 +12192,7 @@ 'model': 'RYSE Shade', 'name': 'Master Bath South', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -12272,6 +12338,7 @@ 'model': 'RYSE SmartBridge', 'name': 'RYSE SmartBridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.3.0', }), @@ -12337,6 +12404,7 @@ 'model': 'RYSE Shade', 'name': 'RYSE SmartShade', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '', }), @@ -12486,6 +12554,7 @@ 'model': 'RYSE Shade', 'name': 'BR Left', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -12631,6 +12700,7 @@ 'model': 'RYSE Shade', 'name': 'LR Left', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -12776,6 +12846,7 @@ 'model': 'RYSE Shade', 'name': 'LR Right', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -12921,6 +12992,7 @@ 'model': 'RYSE SmartBridge', 'name': 'RYSE SmartBridge', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.3.0', }), @@ -12986,6 +13058,7 @@ 'model': 'RYSE Shade', 'name': 'RZSS', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.0.8', }), @@ -13135,6 +13208,7 @@ 'model': 'BE479CAM619', 'name': 'SENSE ', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '004.027.000', }), @@ -13241,6 +13315,7 @@ 'model': 'SIMPLEconnect', 'name': 'SIMPLEconnect Fan-06F674', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '', }), @@ -13400,6 +13475,7 @@ 'model': 'VELUX Gateway', 'name': 'VELUX Gateway', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '70', }), @@ -13465,6 +13541,7 @@ 'model': 'VELUX Sensor', 'name': 'VELUX Sensor', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '16', }), @@ -13653,6 +13730,7 @@ 'model': 'VELUX Window', 'name': 'VELUX Window', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '48', }), @@ -13761,6 +13839,7 @@ 'model': 'Flowerbud', 'name': 'VOCOlinc-Flowerbud-0d324b', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '3.121.2', }), @@ -14030,6 +14109,7 @@ 'model': 'VP3', 'name': 'VOCOlinc-VP3-123456', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '1.101.2', }), diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index f6799d7a691..25d47b342c5 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -71,6 +72,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -108,6 +110,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -227,6 +230,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -264,6 +268,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -289,6 +294,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -326,6 +332,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -363,6 +370,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -400,6 +408,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -437,6 +446,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -474,6 +484,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -511,6 +522,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -876,6 +888,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -913,6 +926,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1032,6 +1046,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1069,6 +1084,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1106,6 +1122,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1143,6 +1160,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1180,6 +1198,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1217,6 +1236,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1254,6 +1274,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 46875b2ab1a..cbcf0d6234e 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -71,6 +72,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -154,6 +156,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -283,6 +286,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -410,6 +414,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -435,6 +440,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -562,6 +568,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -645,6 +652,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1188,6 +1196,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1271,6 +1280,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1354,6 +1364,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1437,6 +1448,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1474,6 +1486,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1695,6 +1708,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1732,6 +1746,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1815,6 +1830,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1898,6 +1914,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2119,6 +2136,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2248,6 +2266,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2423,6 +2442,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2644,6 +2664,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 67d38a09b85..e4d081a409b 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -34,6 +34,7 @@ 'model': 'DS2405', 'name': '05.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -112,6 +113,7 @@ 'model': 'DS18S20', 'name': '10.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -149,6 +151,7 @@ 'model': 'DS2406', 'name': '12.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -350,6 +353,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -387,6 +391,7 @@ 'model': 'DS2409', 'name': '1F.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -412,6 +417,7 @@ 'model': 'DS2423', 'name': '1D.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': , @@ -449,6 +455,7 @@ 'model': 'DS1822', 'name': '22.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -486,6 +493,7 @@ 'model': 'DS2438', 'name': '26.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -564,6 +572,7 @@ 'model': 'DS18B20', 'name': '28.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -601,6 +610,7 @@ 'model': 'DS18B20', 'name': '28.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -638,6 +648,7 @@ 'model': 'DS18B20', 'name': '28.222222222223', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -675,6 +686,7 @@ 'model': 'DS2408', 'name': '29.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1368,6 +1380,7 @@ 'model': 'DS2760', 'name': '30.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1405,6 +1418,7 @@ 'model': 'DS2413', 'name': '3A.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1524,6 +1538,7 @@ 'model': 'DS1825', 'name': '3B.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1561,6 +1576,7 @@ 'model': 'DS28EA00', 'name': '42.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1598,6 +1614,7 @@ 'model': 'EDS0068', 'name': '7E.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1635,6 +1652,7 @@ 'model': 'EDS0066', 'name': '7E.222222222222', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1672,6 +1690,7 @@ 'model': 'HobbyBoards_EF', 'name': 'EF.111111111111', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -1709,6 +1728,7 @@ 'model': 'HB_MOISTURE_METER', 'name': 'EF.111111111112', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -2074,6 +2094,7 @@ 'model': 'HB_HUB', 'name': 'EF.111111111113', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr index 6d5e509ab6b..fbde0470cac 100644 --- a/tests/components/renault/snapshots/test_binary_sensor.ambr +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -299,6 +300,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -656,6 +658,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -813,6 +816,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -1210,6 +1214,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -1487,6 +1492,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -1844,6 +1850,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -2001,6 +2008,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr index 968b20daa5b..90715cb56c2 100644 --- a/tests/components/renault/snapshots/test_button.ambr +++ b/tests/components/renault/snapshots/test_button.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -99,6 +100,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -256,6 +258,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -413,6 +416,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -570,6 +574,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -647,6 +652,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -804,6 +810,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -961,6 +968,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr index 8a215f3fdda..0f901c8ce4c 100644 --- a/tests/components/renault/snapshots/test_device_tracker.ambr +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -100,6 +101,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -178,6 +180,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -215,6 +218,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -293,6 +297,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -374,6 +379,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -455,6 +461,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -492,6 +499,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr index c862e90f289..932a302e5f7 100644 --- a/tests/components/renault/snapshots/test_select.ambr +++ b/tests/components/renault/snapshots/test_select.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -59,6 +60,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -147,6 +149,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -235,6 +238,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -323,6 +327,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -360,6 +365,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -448,6 +454,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -536,6 +543,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr index f49dbf7963f..9fb302a1108 100644 --- a/tests/components/renault/snapshots/test_sensor.ambr +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -312,6 +313,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -1024,6 +1026,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -1730,6 +1733,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, @@ -2476,6 +2480,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -2766,6 +2771,7 @@ 'model': 'Captur ii', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'XJB1SU', 'via_device_id': None, @@ -3478,6 +3484,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X101VE', 'via_device_id': None, @@ -4184,6 +4191,7 @@ 'model': 'Zoe', 'name': 'REG-NUMBER', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'X102VE', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr index 1fc8b672c3f..4eee1208a12 100644 --- a/tests/components/sfr_box/snapshots/test_binary_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_binary_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, @@ -139,6 +140,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_button.ambr b/tests/components/sfr_box/snapshots/test_button.ambr index c216ef6c51d..846da8d41cf 100644 --- a/tests/components/sfr_box/snapshots/test_button.ambr +++ b/tests/components/sfr_box/snapshots/test_button.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/sfr_box/snapshots/test_sensor.ambr b/tests/components/sfr_box/snapshots/test_sensor.ambr index 29cd99403a2..2b1825a40b4 100644 --- a/tests/components/sfr_box/snapshots/test_sensor.ambr +++ b/tests/components/sfr_box/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'NB6VAC-FXC-r0', 'name': 'SFR Box', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': 'NB6VAC-MAIN-R4.0.44k', 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_calendar.ambr b/tests/components/twentemilieu/snapshots/test_calendar.ambr index 6403bd83255..40b9f818f52 100644 --- a/tests/components/twentemilieu/snapshots/test_calendar.ambr +++ b/tests/components/twentemilieu/snapshots/test_calendar.ambr @@ -97,6 +97,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/twentemilieu/snapshots/test_sensor.ambr b/tests/components/twentemilieu/snapshots/test_sensor.ambr index f0e9578ff23..5c9a1e54098 100644 --- a/tests/components/twentemilieu/snapshots/test_sensor.ambr +++ b/tests/components/twentemilieu/snapshots/test_sensor.ambr @@ -66,6 +66,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -138,6 +139,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -210,6 +212,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -282,6 +285,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -354,6 +358,7 @@ 'model': None, 'name': 'Twente Milieu', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/uptime/snapshots/test_sensor.ambr b/tests/components/uptime/snapshots/test_sensor.ambr index 4381cf30647..a078d82ba9f 100644 --- a/tests/components/uptime/snapshots/test_sensor.ambr +++ b/tests/components/uptime/snapshots/test_sensor.ambr @@ -58,6 +58,7 @@ 'model': None, 'name': 'Uptime', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -79,6 +80,7 @@ 'model': None, 'name': 'Uptime', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_fan.ambr b/tests/components/vesync/snapshots/test_fan.ambr index 8dbefd41794..fa60aec2422 100644 --- a/tests/components/vesync/snapshots/test_fan.ambr +++ b/tests/components/vesync/snapshots/test_fan.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -106,6 +107,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -195,6 +197,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -286,6 +289,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -377,6 +381,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -410,6 +415,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -459,6 +465,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -492,6 +499,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -525,6 +533,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_light.ambr b/tests/components/vesync/snapshots/test_light.ambr index 9c0c5ae2811..0ccc169a4ce 100644 --- a/tests/components/vesync/snapshots/test_light.ambr +++ b/tests/components/vesync/snapshots/test_light.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -55,6 +56,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -88,6 +90,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -121,6 +124,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -154,6 +158,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -236,6 +241,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -336,6 +342,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -369,6 +376,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -466,6 +474,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_sensor.ambr b/tests/components/vesync/snapshots/test_sensor.ambr index 7cda1cd0649..bbfc9390634 100644 --- a/tests/components/vesync/snapshots/test_sensor.ambr +++ b/tests/components/vesync/snapshots/test_sensor.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -141,6 +142,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -219,6 +221,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -384,6 +387,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -549,6 +553,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -582,6 +587,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -631,6 +637,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -940,6 +947,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -973,6 +981,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/vesync/snapshots/test_switch.ambr b/tests/components/vesync/snapshots/test_switch.ambr index 95dcb24ded6..6333356f26a 100644 --- a/tests/components/vesync/snapshots/test_switch.ambr +++ b/tests/components/vesync/snapshots/test_switch.ambr @@ -22,6 +22,7 @@ 'model': 'LV-PUR131S', 'name': 'Air Purifier 131s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -55,6 +56,7 @@ 'model': 'Core200S', 'name': 'Air Purifier 200s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -88,6 +90,7 @@ 'model': 'LAP-C401S-WJP', 'name': 'Air Purifier 400s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -121,6 +124,7 @@ 'model': 'LAP-C601S-WUS', 'name': 'Air Purifier 600s', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -154,6 +158,7 @@ 'model': 'ESL100', 'name': 'Dimmable Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -187,6 +192,7 @@ 'model': 'ESWD16', 'name': 'Dimmer Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -236,6 +242,7 @@ 'model': 'wifi-switch-1.3', 'name': 'Outlet', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -310,6 +317,7 @@ 'model': 'ESL100CW', 'name': 'Temperature Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -343,6 +351,7 @@ 'model': 'ESWL01', 'name': 'Wall Switch', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/whois/snapshots/test_sensor.ambr b/tests/components/whois/snapshots/test_sensor.ambr index 519d5894072..83ac2908089 100644 --- a/tests/components/whois/snapshots/test_sensor.ambr +++ b/tests/components/whois/snapshots/test_sensor.ambr @@ -65,6 +65,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -136,6 +137,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -212,6 +214,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -283,6 +286,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -354,6 +358,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -425,6 +430,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -496,6 +502,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -567,6 +574,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, @@ -638,6 +646,7 @@ 'model': None, 'name': 'home-assistant.io', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': None, 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_binary_sensor.ambr b/tests/components/wled/snapshots/test_binary_sensor.ambr index bcf9d7a4cdb..6fc9b2497b5 100644 --- a/tests/components/wled/snapshots/test_binary_sensor.ambr +++ b/tests/components/wled/snapshots/test_binary_sensor.ambr @@ -69,6 +69,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_button.ambr b/tests/components/wled/snapshots/test_button.ambr index b11befe3832..1c65a094662 100644 --- a/tests/components/wled/snapshots/test_button.ambr +++ b/tests/components/wled/snapshots/test_button.ambr @@ -69,6 +69,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_number.ambr b/tests/components/wled/snapshots/test_number.ambr index 509a8860611..47dafe039b2 100644 --- a/tests/components/wled/snapshots/test_number.ambr +++ b/tests/components/wled/snapshots/test_number.ambr @@ -77,6 +77,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -161,6 +162,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index d52c6a10ddd..92604f86d2d 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -80,6 +80,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -260,6 +261,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -344,6 +346,7 @@ 'model': 'DIY light', 'name': 'WLED RGBW Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.6b4', 'via_device_id': None, @@ -428,6 +431,7 @@ 'model': 'DIY light', 'name': 'WLED RGBW Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.6b4', 'via_device_id': None, diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index 52f1e9562e2..feecfd1e1ff 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -72,6 +72,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -147,6 +148,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -223,6 +225,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, @@ -299,6 +302,7 @@ 'model': 'DIY light', 'name': 'WLED RGB Light', 'name_by_user': None, + 'serial_number': None, 'suggested_area': None, 'sw_version': '0.8.5', 'via_device_id': None, diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 380574c04fa..89f4eb5e319 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -198,11 +198,12 @@ async def test_loading_from_storage( "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": "hw_version", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name_by_user": "Test Friendly Name", "name": "name", + "serial_number": "serial_no", "sw_version": "version", "via_device_id": None, } @@ -212,7 +213,7 @@ async def test_loading_from_storage( "config_entries": [mock_config_entry.entry_id], "connections": [["Zigbee", "23.45.67.89.01"]], "id": "bcdefghijklmn", - "identifiers": [["serial", "34:56:AB:CD:EF:12"]], + "identifiers": [["serial", "3456ABCDEF12"]], "orphaned_timestamp": None, } ], @@ -227,7 +228,7 @@ async def test_loading_from_storage( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, manufacturer="manufacturer", model="model", ) @@ -240,11 +241,12 @@ async def test_loading_from_storage( entry_type=dr.DeviceEntryType.SERVICE, hw_version="hw_version", id="abcdefghijklm", - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, manufacturer="manufacturer", model="model", name_by_user="Test Friendly Name", name="name", + serial_number="serial_no", suggested_area=None, # Not stored sw_version="version", ) @@ -256,7 +258,7 @@ async def test_loading_from_storage( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "23.45.67.89.01")}, - identifiers={("serial", "34:56:AB:CD:EF:12")}, + identifiers={("serial", "3456ABCDEF12")}, manufacturer="manufacturer", model="model", ) @@ -264,7 +266,7 @@ async def test_loading_from_storage( config_entries={mock_config_entry.entry_id}, connections={("Zigbee", "23.45.67.89.01")}, id="bcdefghijklmn", - identifiers={("serial", "34:56:AB:CD:EF:12")}, + identifiers={("serial", "3456ABCDEF12")}, manufacturer="manufacturer", model="model", ) @@ -275,12 +277,12 @@ async def test_loading_from_storage( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_1_to_1_3( +async def test_migration_1_1_to_1_4( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, ) -> None: - """Test migration from version 1.1 to 1.3.""" + """Test migration from version 1.1 to 1.4.""" hass_storage[dr.STORAGE_KEY] = { "version": 1, "minor_version": 1, @@ -291,7 +293,7 @@ async def test_migration_1_1_to_1_3( "connections": [["Zigbee", "01.23.45.67.89"]], "entry_type": "service", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -316,7 +318,7 @@ async def test_migration_1_1_to_1_3( "connections": [], "entry_type": "service", "id": "deletedid", - "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "identifiers": [["serial", "123456ABCDFF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -333,7 +335,7 @@ async def test_migration_1_1_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" @@ -341,7 +343,7 @@ async def test_migration_1_1_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, sw_version="new_version", ) assert entry.id == "abcdefghijklm" @@ -363,11 +365,12 @@ async def test_migration_1_1_to_1_3( "entry_type": "service", "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", "name_by_user": None, + "serial_number": None, "sw_version": "new_version", "via_device_id": None, }, @@ -385,6 +388,7 @@ async def test_migration_1_1_to_1_3( "model": None, "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -394,7 +398,7 @@ async def test_migration_1_1_to_1_3( "config_entries": ["123456"], "connections": [], "id": "deletedid", - "identifiers": [["serial", "12:34:56:AB:CD:FF"]], + "identifiers": [["serial", "123456ABCDFF"]], "orphaned_timestamp": None, } ], @@ -403,7 +407,7 @@ async def test_migration_1_1_to_1_3( @pytest.mark.parametrize("load_registries", [False]) -async def test_migration_1_2_to_1_3( +async def test_migration_1_2_to_1_4( hass: HomeAssistant, hass_storage: dict[str, Any], mock_config_entry: MockConfigEntry, @@ -423,7 +427,7 @@ async def test_migration_1_2_to_1_3( "disabled_by": None, "entry_type": "service", "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", @@ -459,7 +463,7 @@ async def test_migration_1_2_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.id == "abcdefghijklm" @@ -467,7 +471,7 @@ async def test_migration_1_2_to_1_3( entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, connections={("Zigbee", "01.23.45.67.89")}, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, sw_version="new_version", ) assert entry.id == "abcdefghijklm" @@ -490,11 +494,12 @@ async def test_migration_1_2_to_1_3( "entry_type": "service", "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": "manufacturer", "model": "model", "name": "name", "name_by_user": None, + "serial_number": None, "sw_version": "new_version", "via_device_id": None, }, @@ -512,6 +517,130 @@ async def test_migration_1_2_to_1_3( "model": None, "name_by_user": None, "name": None, + "serial_number": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + +@pytest.mark.parametrize("load_registries", [False]) +async def test_migration_1_3_to_1_4( + hass: HomeAssistant, + hass_storage: dict[str, Any], + mock_config_entry: MockConfigEntry, +): + """Test migration from version 1.3 to 1.4.""" + hass_storage[dr.STORAGE_KEY] = { + "version": 1, + "minor_version": 3, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "sw_version": "version", + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "sw_version": None, + "via_device_id": None, + }, + ], + "deleted_devices": [], + }, + } + + await dr.async_load(hass) + registry = dr.async_get(hass) + + # Test data was loaded + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "123456ABCDEF")}, + ) + assert entry.id == "abcdefghijklm" + + # Update to trigger a store + entry = registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={("Zigbee", "01.23.45.67.89")}, + identifiers={("serial", "123456ABCDEF")}, + sw_version="new_version", + ) + assert entry.id == "abcdefghijklm" + + # Check we store migrated data + await flush_store(registry._store) + + assert hass_storage[dr.STORAGE_KEY] == { + "version": dr.STORAGE_VERSION_MAJOR, + "minor_version": dr.STORAGE_VERSION_MINOR, + "key": dr.STORAGE_KEY, + "data": { + "devices": [ + { + "area_id": None, + "config_entries": [mock_config_entry.entry_id], + "configuration_url": None, + "connections": [["Zigbee", "01.23.45.67.89"]], + "disabled_by": None, + "entry_type": "service", + "hw_version": "hw_version", + "id": "abcdefghijklm", + "identifiers": [["serial", "123456ABCDEF"]], + "manufacturer": "manufacturer", + "model": "model", + "name": "name", + "name_by_user": None, + "serial_number": None, + "sw_version": "new_version", + "via_device_id": None, + }, + { + "area_id": None, + "config_entries": [None], + "configuration_url": None, + "connections": [], + "disabled_by": None, + "entry_type": None, + "hw_version": None, + "id": "invalid-entry-type", + "identifiers": [["serial", "mock-id-invalid-entry"]], + "manufacturer": None, + "model": None, + "name_by_user": None, + "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, }, @@ -996,6 +1125,7 @@ async def test_update( name_by_user="Test Friendly Name", name="name", new_identifiers=new_identifiers, + serial_number="serial_no", suggested_area="suggested_area", sw_version="version", via_device_id="98765B", @@ -1017,6 +1147,7 @@ async def test_update( model="Test Model", name_by_user="Test Friendly Name", name="name", + serial_number="serial_no", suggested_area="suggested_area", sw_version="version", via_device_id="98765B", @@ -1060,6 +1191,7 @@ async def test_update( "model": None, "name": None, "name_by_user": None, + "serial_number": None, "suggested_area": None, "sw_version": None, "via_device_id": None, @@ -1856,11 +1988,12 @@ async def test_loading_invalid_configuration_url_from_storage( "entry_type": dr.DeviceEntryType.SERVICE, "hw_version": None, "id": "abcdefghijklm", - "identifiers": [["serial", "12:34:56:AB:CD:EF"]], + "identifiers": [["serial", "123456ABCDEF"]], "manufacturer": None, "model": None, "name_by_user": None, "name": None, + "serial_number": None, "sw_version": None, "via_device_id": None, } @@ -1874,6 +2007,6 @@ async def test_loading_invalid_configuration_url_from_storage( assert len(registry.devices) == 1 entry = registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, - identifiers={("serial", "12:34:56:AB:CD:EF")}, + identifiers={("serial", "123456ABCDEF")}, ) assert entry.configuration_url == "invalid" From af264c6e0d1c55280dae11ceec02ed4e0fc2f233 Mon Sep 17 00:00:00 2001 From: Niklas Held <6695866+niklasheld@users.noreply.github.com> Date: Sun, 22 Oct 2023 15:17:22 +0200 Subject: [PATCH 675/968] Fix options-flow in hvv_departures (#102484) --- homeassistant/components/hvv_departures/config_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 4cbd0a83321..24fb9c32a7d 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -150,7 +150,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): try: departure_list = await hub.gti.departureList( { - "station": self.config_entry.data[CONF_STATION], + "station": { + "type": "STATION", + "id": self.config_entry.data[CONF_STATION].get("id"), + }, "time": {"date": "heute", "time": "jetzt"}, "maxList": 5, "maxTimeOffset": 200, From 8bfd418c3e2b9f9a6da3a301af33ed7581d260c2 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 22 Oct 2023 16:17:32 +0200 Subject: [PATCH 676/968] Reach gold level in Minecraft Server (#102462) --- 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 6f11d34cccb..73a7dc18d09 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": "silver", + "quality_scale": "gold", "requirements": ["mcstatus==11.0.0"] } From 51f989c57a6e73681e40bc222bfad748b43589e5 Mon Sep 17 00:00:00 2001 From: Caius-Bonus <123886836+Caius-Bonus@users.noreply.github.com> Date: Sun, 22 Oct 2023 16:18:38 +0200 Subject: [PATCH 677/968] Standardize _select_attr in ZCLEnumSelectEntity (#102454) --- homeassistant/components/zha/select.py | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index b98a0c3f07b..46089dd5a28 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -155,7 +155,7 @@ class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity): class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA ZCL enum select entity.""" - _select_attr: str + _attribute_name: str _attr_entity_category = EntityCategory.CONFIG _enum: type[Enum] @@ -173,13 +173,13 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): """ cluster_handler = cluster_handlers[0] if ( - cls._select_attr in cluster_handler.cluster.unsupported_attributes - or cls._select_attr not in cluster_handler.cluster.attributes_by_name - or cluster_handler.cluster.get(cls._select_attr) is None + cls._attribute_name in cluster_handler.cluster.unsupported_attributes + or cls._attribute_name not in cluster_handler.cluster.attributes_by_name + or cluster_handler.cluster.get(cls._attribute_name) is None ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", - cls._select_attr, + cls._attribute_name, cls.__name__, ) return None @@ -201,7 +201,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the selected entity option to represent the entity state.""" - option = self._cluster_handler.cluster.get(self._select_attr) + option = self._cluster_handler.cluster.get(self._attribute_name) if option is None: return None option = self._enum(option) @@ -210,7 +210,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._cluster_handler.write_attributes_safe( - {self._select_attr: self._enum[option.replace(" ", "_")]} + {self._attribute_name: self._enum[option.replace(" ", "_")]} ) self.async_write_ha_state() @@ -232,7 +232,7 @@ class ZHAStartupOnOffSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA startup onoff select entity.""" _unique_id_suffix = OnOff.StartUpOnOff.__name__ - _select_attr = "start_up_on_off" + _attribute_name = "start_up_on_off" _enum = OnOff.StartUpOnOff _attr_translation_key: str = "start_up_on_off" @@ -274,7 +274,7 @@ class TuyaPowerOnStateSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA power on state select entity.""" _unique_id_suffix = "power_on_state" - _select_attr = "power_on_state" + _attribute_name = "power_on_state" _enum = TuyaPowerOnState _attr_translation_key: str = "power_on_state" @@ -295,7 +295,7 @@ class TuyaBacklightModeSelectEntity(ZCLEnumSelectEntity): """Representation of a ZHA backlight mode select entity.""" _unique_id_suffix = "backlight_mode" - _select_attr = "backlight_mode" + _attribute_name = "backlight_mode" _enum = TuyaBacklightMode _attr_translation_key: str = "backlight_mode" @@ -334,7 +334,7 @@ class MoesBacklightModeSelectEntity(ZCLEnumSelectEntity): """Moes devices have a different backlight mode select options.""" _unique_id_suffix = "backlight_mode" - _select_attr = "backlight_mode" + _attribute_name = "backlight_mode" _enum = MoesBacklightMode _attr_translation_key: str = "backlight_mode" @@ -355,7 +355,7 @@ class AqaraMotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" _unique_id_suffix = "motion_sensitivity" - _select_attr = "motion_sensitivity" + _attribute_name = "motion_sensitivity" _enum = AqaraMotionSensitivities _attr_translation_key: str = "motion_sensitivity" @@ -377,7 +377,7 @@ class HueV1MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" _unique_id_suffix = "motion_sensitivity" - _select_attr = "sensitivity" + _attribute_name = "sensitivity" _enum = HueV1MotionSensitivities _attr_translation_key: str = "motion_sensitivity" @@ -401,7 +401,7 @@ class HueV2MotionSensitivity(ZCLEnumSelectEntity): """Representation of a ZHA motion sensitivity configuration entity.""" _unique_id_suffix = "motion_sensitivity" - _select_attr = "sensitivity" + _attribute_name = "sensitivity" _enum = HueV2MotionSensitivities _attr_translation_key: str = "motion_sensitivity" @@ -420,7 +420,7 @@ class AqaraMonitoringMode(ZCLEnumSelectEntity): """Representation of a ZHA monitoring mode configuration entity.""" _unique_id_suffix = "monitoring_mode" - _select_attr = "monitoring_mode" + _attribute_name = "monitoring_mode" _enum = AqaraMonitoringModess _attr_translation_key: str = "monitoring_mode" @@ -440,7 +440,7 @@ class AqaraApproachDistance(ZCLEnumSelectEntity): """Representation of a ZHA approach distance configuration entity.""" _unique_id_suffix = "approach_distance" - _select_attr = "approach_distance" + _attribute_name = "approach_distance" _enum = AqaraApproachDistances _attr_translation_key: str = "approach_distance" @@ -459,7 +459,7 @@ class AqaraCurtainMode(ZCLEnumSelectEntity): """Representation of a ZHA curtain mode configuration entity.""" _unique_id_suffix = "window_covering_mode" - _select_attr = "window_covering_mode" + _attribute_name = "window_covering_mode" _enum = AqaraE1ReverseDirection _attr_translation_key: str = "window_covering_mode" @@ -478,7 +478,7 @@ class InovelliOutputModeEntity(ZCLEnumSelectEntity): """Inovelli output mode control.""" _unique_id_suffix = "output_mode" - _select_attr = "output_mode" + _attribute_name = "output_mode" _enum = InovelliOutputMode _attr_translation_key: str = "output_mode" @@ -499,7 +499,7 @@ class InovelliSwitchTypeEntity(ZCLEnumSelectEntity): """Inovelli switch type control.""" _unique_id_suffix = "switch_type" - _select_attr = "switch_type" + _attribute_name = "switch_type" _enum = InovelliSwitchType _attr_translation_key: str = "switch_type" @@ -518,7 +518,7 @@ class InovelliLedScalingModeEntity(ZCLEnumSelectEntity): """Inovelli led mode control.""" _unique_id_suffix = "led_scaling_mode" - _select_attr = "led_scaling_mode" + _attribute_name = "led_scaling_mode" _enum = InovelliLedScalingMode _attr_translation_key: str = "led_scaling_mode" @@ -537,7 +537,7 @@ class InovelliNonNeutralOutputEntity(ZCLEnumSelectEntity): """Inovelli non neutral output control.""" _unique_id_suffix = "increased_non_neutral_output" - _select_attr = "increased_non_neutral_output" + _attribute_name = "increased_non_neutral_output" _enum = InovelliNonNeutralOutput _attr_translation_key: str = "increased_non_neutral_output" @@ -556,7 +556,7 @@ class AqaraPetFeederMode(ZCLEnumSelectEntity): """Representation of an Aqara pet feeder mode configuration entity.""" _unique_id_suffix = "feeding_mode" - _select_attr = "feeding_mode" + _attribute_name = "feeding_mode" _enum = AqaraFeedingMode _attr_translation_key: str = "feeding_mode" _attr_icon: str = "mdi:wrench-clock" @@ -577,6 +577,6 @@ class AqaraThermostatPreset(ZCLEnumSelectEntity): """Representation of an Aqara thermostat preset configuration entity.""" _unique_id_suffix = "preset" - _select_attr = "preset" + _attribute_name = "preset" _enum = AqaraThermostatPresetMode _attr_translation_key: str = "preset" From 06a2664a07be123157bb965d4c42715e946dab12 Mon Sep 17 00:00:00 2001 From: Hessel Date: Sun, 22 Oct 2023 16:22:35 +0200 Subject: [PATCH 678/968] Wallbox Improve Testing (#102519) --- tests/components/wallbox/__init__.py | 88 ++++++++++++--------------- tests/components/wallbox/test_init.py | 28 +++++++++ 2 files changed, 66 insertions(+), 50 deletions(-) diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index d9bf9cfceaf..40f55db8d50 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -29,28 +29,24 @@ from .const import ERROR, STATUS, TTL, USER_ID from tests.common import MockConfigEntry -test_response = json.loads( - json.dumps( - { - CHARGER_CHARGING_POWER_KEY: 0, - CHARGER_STATUS_ID_KEY: 193, - CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, - CHARGER_CHARGING_SPEED_KEY: 0, - CHARGER_ADDED_RANGE_KEY: 150, - CHARGER_ADDED_ENERGY_KEY: 44.697, - CHARGER_NAME_KEY: "WallboxName", - CHARGER_DATA_KEY: { - CHARGER_MAX_CHARGING_CURRENT_KEY: 24, - CHARGER_ENERGY_PRICE_KEY: 0.4, - CHARGER_LOCKED_UNLOCKED_KEY: False, - CHARGER_SERIAL_NUMBER_KEY: "20000", - CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", - CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, - CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, - }, - } - ) -) +test_response = { + CHARGER_CHARGING_POWER_KEY: 0, + CHARGER_STATUS_ID_KEY: 193, + CHARGER_MAX_AVAILABLE_POWER_KEY: 25.0, + CHARGER_CHARGING_SPEED_KEY: 0, + CHARGER_ADDED_RANGE_KEY: 150, + CHARGER_ADDED_ENERGY_KEY: 44.697, + CHARGER_NAME_KEY: "WallboxName", + CHARGER_DATA_KEY: { + CHARGER_MAX_CHARGING_CURRENT_KEY: 24, + CHARGER_ENERGY_PRICE_KEY: 0.4, + CHARGER_LOCKED_UNLOCKED_KEY: False, + CHARGER_SERIAL_NUMBER_KEY: "20000", + CHARGER_PART_NUMBER_KEY: "PLP1-0-2-4-9-002-E", + CHARGER_SOFTWARE_KEY: {CHARGER_CURRENT_VERSION_KEY: "5.5.10"}, + CHARGER_CURRENCY_KEY: {"code": "EUR/kWh"}, + }, +} test_response_bidir = { CHARGER_CHARGING_POWER_KEY: 0, @@ -72,38 +68,30 @@ test_response_bidir = { } -authorisation_response = json.loads( - json.dumps( - { - "data": { - "attributes": { - "token": "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - } +authorisation_response = { + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 200, } - ) -) + } +} -authorisation_response_unauthorised = json.loads( - json.dumps( - { - "data": { - "attributes": { - "token": "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 404, - } - } +authorisation_response_unauthorised = { + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 404, } - ) -) + } +} async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None: diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index 0091ce9ffdc..93082737f1f 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -45,6 +45,34 @@ async def test_wallbox_unload_entry_connection_error( assert entry.state == ConfigEntryState.NOT_LOADED +async def test_wallbox_refresh_failed_connection_error_auth( + hass: HomeAssistant, entry: MockConfigEntry +) -> None: + """Test Wallbox setup with connection error.""" + + await setup_integration(hass, entry) + assert entry.state == ConfigEntryState.LOADED + + with requests_mock.Mocker() as mock_request: + mock_request.get( + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, + status_code=404, + ) + mock_request.get( + "https://api.wall-box.com/chargers/status/12345", + json=test_response, + status_code=200, + ) + + wallbox = hass.data[DOMAIN][entry.entry_id] + + await wallbox.async_refresh() + + assert await hass.config_entries.async_unload(entry.entry_id) + assert entry.state == ConfigEntryState.NOT_LOADED + + async def test_wallbox_refresh_failed_invalid_auth( hass: HomeAssistant, entry: MockConfigEntry ) -> None: From 3259e39170ad198ef04ac78032f08b7beeb9551b Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 22 Oct 2023 16:30:34 +0200 Subject: [PATCH 679/968] Migrate Nuki to use dataclass for entry data (#101785) Co-authored-by: J. Nick Koston Co-authored-by: Franck Nijhof --- homeassistant/components/nuki/__init__.py | 45 ++++++++++--------- .../components/nuki/binary_sensor.py | 11 +++-- homeassistant/components/nuki/const.py | 6 --- homeassistant/components/nuki/lock.py | 13 +++--- homeassistant/components/nuki/sensor.py | 9 ++-- 5 files changed, 39 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 3b846d73477..ede7a20ccdb 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import defaultdict +from dataclasses import dataclass from datetime import timedelta from http import HTTPStatus import logging @@ -38,15 +39,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import ( - DATA_BRIDGE, - DATA_COORDINATOR, - DATA_LOCKS, - DATA_OPENERS, - DEFAULT_TIMEOUT, - DOMAIN, - ERROR_STATES, -) +from .const import DEFAULT_TIMEOUT, DOMAIN, ERROR_STATES from .helpers import NukiWebhookException, parse_id _NukiDeviceT = TypeVar("_NukiDeviceT", bound=NukiDevice) @@ -57,6 +50,16 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK, Platform.SENSOR] UPDATE_INTERVAL = timedelta(seconds=30) +@dataclass(slots=True) +class NukiEntryData: + """Class to hold Nuki data.""" + + coordinator: NukiCoordinator + bridge: NukiBridge + locks: list[NukiLock] + openers: list[NukiOpener] + + def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers @@ -74,14 +77,15 @@ async def _create_webhook( except ValueError: return web.Response(status=HTTPStatus.BAD_REQUEST) - locks = hass.data[DOMAIN][entry.entry_id][DATA_LOCKS] - openers = hass.data[DOMAIN][entry.entry_id][DATA_OPENERS] + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + locks = entry_data.locks + openers = entry_data.openers devices = [x for x in locks + openers if x.nuki_id == data["nukiId"]] if len(devices) == 1: devices[0].update_from_callback(data) - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator = entry_data.coordinator coordinator.async_set_updated_data(None) return web.Response(status=HTTPStatus.OK) @@ -232,13 +236,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) coordinator = NukiCoordinator(hass, bridge, locks, openers) - - hass.data[DOMAIN][entry.entry_id] = { - DATA_COORDINATOR: coordinator, - DATA_BRIDGE: bridge, - DATA_LOCKS: locks, - DATA_OPENERS: openers, - } + hass.data[DOMAIN][entry.entry_id] = NukiEntryData( + coordinator=coordinator, + bridge=bridge, + locks=locks, + openers=openers, + ) # Fetch initial data so we have data when entities subscribe await coordinator.async_refresh() @@ -251,11 +254,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload the Nuki entry.""" webhook.async_unregister(hass, entry.entry_id) + entry_data: NukiEntryData = hass.data[DOMAIN][entry.entry_id] + try: async with asyncio.timeout(10): await hass.async_add_executor_job( _remove_webhook, - hass.data[DOMAIN][entry.entry_id][DATA_BRIDGE], + entry_data.bridge, entry.entry_id, ) except InvalidCredentialsException as err: diff --git a/homeassistant/components/nuki/binary_sensor.py b/homeassistant/components/nuki/binary_sensor.py index 86c7f8343df..240bb2dc525 100644 --- a/homeassistant/components/nuki/binary_sensor.py +++ b/homeassistant/components/nuki/binary_sensor.py @@ -12,22 +12,21 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiCoordinator, NukiEntity -from .const import ATTR_NUKI_ID, DATA_COORDINATOR, DATA_LOCKS, DOMAIN as NUKI_DOMAIN +from . import NukiEntity, NukiEntryData +from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock binary sensor.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator: NukiCoordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] entities = [] - for lock in data[DATA_LOCKS]: + for lock in entry_data.locks: if lock.is_door_sensor_activated: - entities.extend([NukiDoorsensorEntity(coordinator, lock)]) + entities.extend([NukiDoorsensorEntity(entry_data.coordinator, lock)]) async_add_entities(entities) diff --git a/homeassistant/components/nuki/const.py b/homeassistant/components/nuki/const.py index 680454c3edc..dee4a8b8ac5 100644 --- a/homeassistant/components/nuki/const.py +++ b/homeassistant/components/nuki/const.py @@ -7,12 +7,6 @@ ATTR_NUKI_ID = "nuki_id" ATTR_ENABLE = "enable" ATTR_UNLATCH = "unlatch" -# Data -DATA_BRIDGE = "nuki_bridge_data" -DATA_LOCKS = "nuki_locks_data" -DATA_OPENERS = "nuki_openers_data" -DATA_COORDINATOR = "nuki_coordinator" - # Defaults DEFAULT_PORT = 8080 DEFAULT_TIMEOUT = 20 diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index a1a75ef8260..f1e553e6668 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -16,15 +16,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiCoordinator, NukiEntity +from . import NukiEntity, NukiEntryData from .const import ( ATTR_BATTERY_CRITICAL, ATTR_ENABLE, ATTR_NUKI_ID, ATTR_UNLATCH, - DATA_COORDINATOR, - DATA_LOCKS, - DATA_OPENERS, DOMAIN as NUKI_DOMAIN, ERROR_STATES, ) @@ -37,14 +34,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock platform.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator: NukiCoordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] + coordinator = entry_data.coordinator entities: list[NukiDeviceEntity] = [ - NukiLockEntity(coordinator, lock) for lock in data[DATA_LOCKS] + NukiLockEntity(coordinator, lock) for lock in entry_data.locks ] entities.extend( - [NukiOpenerEntity(coordinator, opener) for opener in data[DATA_OPENERS]] + [NukiOpenerEntity(coordinator, opener) for opener in entry_data.openers] ) async_add_entities(entities) diff --git a/homeassistant/components/nuki/sensor.py b/homeassistant/components/nuki/sensor.py index 06cfa065c54..3c6775cd171 100644 --- a/homeassistant/components/nuki/sensor.py +++ b/homeassistant/components/nuki/sensor.py @@ -9,19 +9,18 @@ from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import NukiEntity -from .const import ATTR_NUKI_ID, DATA_COORDINATOR, DATA_LOCKS, DOMAIN as NUKI_DOMAIN +from . import NukiEntity, NukiEntryData +from .const import ATTR_NUKI_ID, DOMAIN as NUKI_DOMAIN async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Nuki lock sensor.""" - data = hass.data[NUKI_DOMAIN][entry.entry_id] - coordinator = data[DATA_COORDINATOR] + entry_data: NukiEntryData = hass.data[NUKI_DOMAIN][entry.entry_id] async_add_entities( - NukiBatterySensor(coordinator, lock) for lock in data[DATA_LOCKS] + NukiBatterySensor(entry_data.coordinator, lock) for lock in entry_data.locks ) From 1412c2ea6e6e34fde8364304d1305230513d8fe6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 16:52:38 +0200 Subject: [PATCH 680/968] Add serial number to ViCare (#102530) --- homeassistant/components/vicare/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vicare/entity.py b/homeassistant/components/vicare/entity.py index e3313446812..089f9c062b8 100644 --- a/homeassistant/components/vicare/entity.py +++ b/homeassistant/components/vicare/entity.py @@ -15,6 +15,7 @@ class ViCareEntity(Entity): self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_config.getConfig().serial)}, + serial_number=device_config.getConfig().serial, name=device_config.getModel(), manufacturer="Viessmann", model=device_config.getModel(), From 58e84b7ba1153a023903348622b8ece0355b834e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 16:52:55 +0200 Subject: [PATCH 681/968] Add serial number to Roomba (#102529) --- homeassistant/components/roomba/irobot_base.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 451cdfc4c46..ffa4e2d8292 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -94,6 +94,7 @@ class IRobotEntity(Entity): return DeviceInfo( connections=connections, identifiers={(DOMAIN, self.robot_unique_id)}, + serial_number=self.vacuum_state.get("hwPartsRev", {}).get("navSerialNo"), manufacturer="iRobot", model=self._sku, name=str(self._name), From ee8037afc186c8d7d50903074a72a51c6524643a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 16:53:04 +0200 Subject: [PATCH 682/968] Add serial number to Nuheat (#102527) --- homeassistant/components/nuheat/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 4daaee10ea6..13a46c0b32f 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -251,6 +251,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): """Return the device_info of the device.""" return DeviceInfo( identifiers={(DOMAIN, self._thermostat.serial_number)}, + serial_number=self._thermostat.serial_number, name=self._thermostat.room, model="nVent Signature", manufacturer=MANUFACTURER, From 1a8558012f05fff7fbd01a5605a712f5211aa659 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 16:53:17 +0200 Subject: [PATCH 683/968] Add serial number to Flo (#102526) --- homeassistant/components/flo/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/flo/entity.py b/homeassistant/components/flo/entity.py index 066ffef6a05..2745f5f9fb7 100644 --- a/homeassistant/components/flo/entity.py +++ b/homeassistant/components/flo/entity.py @@ -32,6 +32,7 @@ class FloEntity(Entity): return DeviceInfo( connections={(CONNECTION_NETWORK_MAC, self._device.mac_address)}, identifiers={(FLO_DOMAIN, self._device.id)}, + serial_number=self._device.serial_number, manufacturer=self._device.manufacturer, model=self._device.model, name=self._device.device_name.capitalize(), From 392b53e25694af61ddd49ed0d5e4b3e319900b4c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 16:53:29 +0200 Subject: [PATCH 684/968] Add serial number to Fibaro (#102525) --- homeassistant/components/fibaro/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 90b1f0a5425..55b41372faa 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -406,6 +406,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, controller.hub_serial)}, + serial_number=controller.hub_serial, manufacturer="Fibaro", name=controller.hub_name, model=controller.hub_serial, From e4943dd1e6a61a082450385465087d66ad9c3df3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 17:13:36 +0200 Subject: [PATCH 685/968] Add serial number to Qnap (#102528) --- homeassistant/components/qnap/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 4bf410c7f87..dfd03deca16 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -351,6 +351,7 @@ class QNAPSensor(CoordinatorEntity[QnapCoordinator], SensorEntity): self._attr_unique_id = f"{self._attr_unique_id}_{monitor_device}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, unique_id)}, + serial_number=unique_id, name=self.device_name, model=self.coordinator.data["system_stats"]["system"]["model"], sw_version=self.coordinator.data["system_stats"]["firmware"]["version"], From b79eae2e94257ad9ccdfd6433dfcb35edf5e2d4d Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sun, 22 Oct 2023 17:14:49 +0200 Subject: [PATCH 686/968] Update Unifi bandwidth sensors (#101598) * Change bandwidth sensors device class, state class, unit of measurement, icon * Reformat imports * Reformat imports * Revert suggested_unit_of_measurement. Add unit tests. --- homeassistant/components/unifi/sensor.py | 15 +++++++---- tests/components/unifi/test_sensor.py | 32 +++++++++++++++++------- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 86c6b0d6352..f07269dfdd2 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,10 +27,11 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower +from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfPower from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util @@ -133,8 +134,10 @@ class UnifiSensorEntityDescription( ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor RX", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + icon="mdi:upload", has_entity_name=True, allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, api_handler_fn=lambda api: api.clients, @@ -151,8 +154,10 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( ), UnifiSensorEntityDescription[Clients, Client]( key="Bandwidth sensor TX", - entity_category=EntityCategory.DIAGNOSTIC, - native_unit_of_measurement=UnitOfInformation.MEGABYTES, + device_class=SensorDeviceClass.DATA_RATE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, + icon="mdi:download", has_entity_name=True, allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, api_handler_fn=lambda api: api.clients, diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index b652c38abdb..f4366b98fc3 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -8,9 +8,11 @@ import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.sensor import ( + ATTR_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, SCAN_INTERVAL, SensorDeviceClass, + SensorStateClass, ) from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -354,16 +356,28 @@ async def test_bandwidth_sensors( assert len(hass.states.async_all()) == 5 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 - assert hass.states.get("sensor.wired_client_rx").state == "1234.0" - assert hass.states.get("sensor.wired_client_tx").state == "5678.0" - assert hass.states.get("sensor.wireless_client_rx").state == "2345.0" - assert hass.states.get("sensor.wireless_client_tx").state == "6789.0" - ent_reg = er.async_get(hass) - assert ( - ent_reg.async_get("sensor.wired_client_rx").entity_category - is EntityCategory.DIAGNOSTIC - ) + # Verify sensor attributes and state + + wrx_sensor = hass.states.get("sensor.wired_client_rx") + assert wrx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wrx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wrx_sensor.state == "1234.0" + + wtx_sensor = hass.states.get("sensor.wired_client_tx") + assert wtx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wtx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wtx_sensor.state == "5678.0" + + wlrx_sensor = hass.states.get("sensor.wireless_client_rx") + assert wlrx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wlrx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wlrx_sensor.state == "2345.0" + + wltx_sensor = hass.states.get("sensor.wireless_client_tx") + assert wltx_sensor.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.DATA_RATE + assert wltx_sensor.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT + assert wltx_sensor.state == "6789.0" # Verify state update From a04c37c59f0e2cd90fb3b92ddfdeda80bd948cc3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 22 Oct 2023 18:10:47 +0200 Subject: [PATCH 687/968] Add serial number to Discovergy (#102531) --- homeassistant/components/discovergy/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/discovergy/sensor.py b/homeassistant/components/discovergy/sensor.py index 5b8fb864987..0f5ace28dd7 100644 --- a/homeassistant/components/discovergy/sensor.py +++ b/homeassistant/components/discovergy/sensor.py @@ -219,8 +219,9 @@ class DiscovergySensor(CoordinatorEntity[DiscovergyUpdateCoordinator], SensorEnt self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, meter.meter_id)}, name=f"{meter.measurement_type.capitalize()} {meter.location.street} {meter.location.street_number}", - model=f"{meter.type} {meter.full_serial_number}", + model=meter.type, manufacturer=MANUFACTURER, + serial_number=meter.full_serial_number, ) @property From af0b53cc79e519df190beb41003e7218fda19c00 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 18:34:01 +0200 Subject: [PATCH 688/968] Add serial number to Axis (#102522) Co-authored-by: Robert Svensson --- homeassistant/components/axis/entity.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/axis/entity.py b/homeassistant/components/axis/entity.py index 37be5355800..5a1fede53c7 100644 --- a/homeassistant/components/axis/entity.py +++ b/homeassistant/components/axis/entity.py @@ -42,7 +42,8 @@ class AxisEntity(Entity): self.device = device self._attr_device_info = DeviceInfo( - identifiers={(AXIS_DOMAIN, device.unique_id)} + identifiers={(AXIS_DOMAIN, device.unique_id)}, + serial_number=device.unique_id, ) async def async_added_to_hass(self) -> None: From 82c0610050b70ebb921f5debde5e92ff97cee3e0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 06:40:48 -1000 Subject: [PATCH 689/968] Avoid core/supervisor stats API calls when no entities need them (#102362) --- homeassistant/components/hassio/__init__.py | 66 +++++++++++---------- homeassistant/components/hassio/const.py | 19 +++--- homeassistant/components/hassio/entity.py | 24 +++++++- 3 files changed, 68 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index a471bf820c8..392671a5471 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -57,9 +57,6 @@ from .addon_manager import AddonError, AddonInfo, AddonManager, AddonState # no from .addon_panel import async_setup_addon_panel from .auth import async_setup_auth_view from .const import ( - ADDON_UPDATE_CHANGELOG, - ADDON_UPDATE_INFO, - ADDON_UPDATE_STATS, ATTR_ADDON, ATTR_ADDONS, ATTR_AUTO_UPDATE, @@ -76,6 +73,10 @@ from .const import ( ATTR_STATE, ATTR_URL, ATTR_VERSION, + CONTAINER_CHANGELOG, + CONTAINER_INFO, + CONTAINER_STATS, + CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, @@ -83,6 +84,7 @@ from .const import ( DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + SUPERVISOR_CONTAINER, SupervisorEntityModel, ) from .discovery import HassioServiceInfo, async_setup_discovery_view # noqa: F401 @@ -805,9 +807,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): self.entry_id = config_entry.entry_id self.dev_reg = dev_reg self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None - self._enabled_updates_by_addon: defaultdict[ - str, dict[str, set[str]] - ] = defaultdict(lambda: defaultdict(set)) + self._container_updates: defaultdict[str, dict[str, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" @@ -910,23 +912,24 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): async def force_data_refresh(self, first_update: bool) -> None: """Force update of the addon info.""" + container_updates = self._container_updates + data = self.hass.data hassio = self.hassio - ( - data[DATA_INFO], - data[DATA_CORE_INFO], - data[DATA_CORE_STATS], - data[DATA_SUPERVISOR_INFO], - data[DATA_SUPERVISOR_STATS], - data[DATA_OS_INFO], - ) = await asyncio.gather( - hassio.get_info(), - hassio.get_core_info(), - hassio.get_core_stats(), - hassio.get_supervisor_info(), - hassio.get_supervisor_stats(), - hassio.get_os_info(), - ) + updates = { + DATA_INFO: hassio.get_info(), + DATA_CORE_INFO: hassio.get_core_info(), + DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), + DATA_OS_INFO: hassio.get_os_info(), + } + if first_update or CONTAINER_STATS in container_updates[CORE_CONTAINER]: + updates[DATA_CORE_STATS] = hassio.get_core_stats() + if first_update or CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: + updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() + + results = await asyncio.gather(*updates.values()) + for key, result in zip(updates, results): + data[key] = result _addon_data = data[DATA_SUPERVISOR_INFO].get("addons", []) all_addons: list[str] = [] @@ -940,37 +943,36 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # Update add-on info if its the first update or # there is at least one entity that needs the data. # - # When entities are added they call async_enable_addon_updates + # When entities are added they call async_enable_container_updates # to enable updates for the endpoints they need via # async_added_to_hass. This ensures that we only update # the data for the endpoints that are needed to avoid unnecessary - # API calls since otherwise we would fetch stats for all add-ons + # API calls since otherwise we would fetch stats for all containers # and throw them away. # - enabled_updates_by_addon = self._enabled_updates_by_addon for data_key, update_func, enabled_key, wanted_addons in ( ( DATA_ADDONS_STATS, self._update_addon_stats, - ADDON_UPDATE_STATS, + CONTAINER_STATS, started_addons, ), ( DATA_ADDONS_CHANGELOGS, self._update_addon_changelog, - ADDON_UPDATE_CHANGELOG, + CONTAINER_CHANGELOG, all_addons, ), - (DATA_ADDONS_INFO, self._update_addon_info, ADDON_UPDATE_INFO, all_addons), + (DATA_ADDONS_INFO, self._update_addon_info, CONTAINER_INFO, all_addons), ): - data.setdefault(data_key, {}).update( + container_data: dict[str, Any] = data.setdefault(data_key, {}) + container_data.update( dict( await asyncio.gather( *[ update_func(slug) for slug in wanted_addons - if first_update - or enabled_key in enabled_updates_by_addon[slug] + if first_update or enabled_key in container_updates[slug] ] ) ) @@ -1004,11 +1006,11 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): return (slug, None) @callback - def async_enable_addon_updates( + def async_enable_container_updates( self, slug: str, entity_id: str, types: set[str] ) -> CALLBACK_TYPE: """Enable updates for an add-on.""" - enabled_updates = self._enabled_updates_by_addon[slug] + enabled_updates = self._container_updates[slug] for key in types: enabled_updates[key].add(entity_id) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 3d2ff7b0cff..9b52057a914 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -82,19 +82,22 @@ PLACEHOLDER_KEY_COMPONENTS = "components" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" -ADDON_UPDATE_STATS = "stats" -ADDON_UPDATE_CHANGELOG = "changelog" -ADDON_UPDATE_INFO = "info" +CORE_CONTAINER = "homeassistant" +SUPERVISOR_CONTAINER = "hassio_supervisor" + +CONTAINER_STATS = "stats" +CONTAINER_CHANGELOG = "changelog" +CONTAINER_INFO = "info" # This is a mapping of which endpoint the key in the addon data # is obtained from so we know which endpoint to update when the # coordinator polls for updates. KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { - ATTR_VERSION_LATEST: {ADDON_UPDATE_INFO, ADDON_UPDATE_CHANGELOG}, - ATTR_MEMORY_PERCENT: {ADDON_UPDATE_STATS}, - ATTR_CPU_PERCENT: {ADDON_UPDATE_STATS}, - ATTR_VERSION: {ADDON_UPDATE_INFO}, - ATTR_STATE: {ADDON_UPDATE_INFO}, + ATTR_VERSION_LATEST: {CONTAINER_INFO, CONTAINER_CHANGELOG}, + ATTR_MEMORY_PERCENT: {CONTAINER_STATS}, + ATTR_CPU_PERCENT: {CONTAINER_STATS}, + ATTR_VERSION: {CONTAINER_INFO}, + ATTR_STATE: {CONTAINER_INFO}, } diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 5421a3ea953..16e418d91d5 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -10,12 +10,14 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator from .const import ( ATTR_SLUG, + CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, DATA_KEY_HOST, DATA_KEY_OS, DATA_KEY_SUPERVISOR, KEY_TO_UPDATE_TYPES, + SUPERVISOR_CONTAINER, ) @@ -52,7 +54,7 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): await super().async_added_to_hass() update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] self.async_on_remove( - self.coordinator.async_enable_addon_updates( + self.coordinator.async_enable_container_updates( self._addon_slug, self.entity_id, update_types ) ) @@ -136,6 +138,16 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): in self.coordinator.data[DATA_KEY_SUPERVISOR] ) + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_container_updates( + SUPERVISOR_CONTAINER, self.entity_id, update_types + ) + ) + class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): """Base Entity for Core.""" @@ -161,3 +173,13 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): and DATA_KEY_CORE in self.coordinator.data and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE] ) + + async def async_added_to_hass(self) -> None: + """Subscribe to updates.""" + await super().async_added_to_hass() + update_types = KEY_TO_UPDATE_TYPES[self.entity_description.key] + self.async_on_remove( + self.coordinator.async_enable_container_updates( + CORE_CONTAINER, self.entity_id, update_types + ) + ) From 0b5218ec968914f5ca727dba28f871c4f629c96b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 19:30:05 +0200 Subject: [PATCH 690/968] Migrate SolarEdge to has entity name (#98944) --- homeassistant/components/solaredge/sensor.py | 27 ++++++++------------ 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index f2c073c6918..ca998493237 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfEnergy, UnitOfPower from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -221,9 +222,7 @@ async def async_setup_entry( # Add the needed sensors to hass api: Solaredge = hass.data[DOMAIN][entry.entry_id][DATA_API_CLIENT] - sensor_factory = SolarEdgeSensorFactory( - hass, entry.title, entry.data[CONF_SITE_ID], api - ) + sensor_factory = SolarEdgeSensorFactory(hass, entry.data[CONF_SITE_ID], api) for service in sensor_factory.all_services: service.async_setup() await service.coordinator.async_refresh() @@ -239,11 +238,8 @@ async def async_setup_entry( class SolarEdgeSensorFactory: """Factory which creates sensors based on the sensor_key.""" - def __init__( - self, hass: HomeAssistant, platform_name: str, site_id: str, api: Solaredge - ) -> None: + def __init__(self, hass: HomeAssistant, site_id: str, api: Solaredge) -> None: """Initialize the factory.""" - self.platform_name = platform_name details = SolarEdgeDetailsDataService(hass, api, site_id) overview = SolarEdgeOverviewDataService(hass, api, site_id) @@ -294,7 +290,7 @@ class SolarEdgeSensorFactory: """Create and return a sensor based on the sensor_key.""" sensor_class, service = self.services[sensor_type.key] - return sensor_class(self.platform_name, sensor_type, service) + return sensor_class(sensor_type, service) class SolarEdgeSensorEntity( @@ -302,21 +298,22 @@ class SolarEdgeSensorEntity( ): """Abstract class for a solaredge sensor.""" + _attr_has_entity_name = True + entity_description: SolarEdgeSensorEntityDescription def __init__( self, - platform_name: str, description: SolarEdgeSensorEntityDescription, data_service: SolarEdgeDataService, ) -> None: """Initialize the sensor.""" super().__init__(data_service.coordinator) - self.platform_name = platform_name self.entity_description = description self.data_service = data_service - - self._attr_name = f"{platform_name} ({description.name})" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, data_service.site_id)}, manufacturer="SolarEdge" + ) @property def unique_id(self) -> str | None: @@ -375,12 +372,11 @@ class SolarEdgeEnergyDetailsSensor(SolarEdgeSensorEntity): def __init__( self, - platform_name: str, sensor_type: SolarEdgeSensorEntityDescription, data_service: SolarEdgeEnergyDetailsService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, sensor_type, data_service) + super().__init__(sensor_type, data_service) self._attr_native_unit_of_measurement = data_service.unit @@ -402,12 +398,11 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensorEntity): def __init__( self, - platform_name: str, description: SolarEdgeSensorEntityDescription, data_service: SolarEdgePowerFlowDataService, ) -> None: """Initialize the power flow sensor.""" - super().__init__(platform_name, description, data_service) + super().__init__(description, data_service) self._attr_native_unit_of_measurement = data_service.unit From f8ed051f01295cfe288219d88328f9250d6cb9d9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 19:38:48 +0200 Subject: [PATCH 691/968] Bump aiowithings to 1.0.1 (#102532) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index dbee20ca32b..a1df31ceecc 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==1.0.0"] + "requirements": ["aiowithings==1.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4b730b6b0e7..d627e5ad004 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.0 +aiowithings==1.0.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 95cac4a6dc7..27cc3d1a0e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.0 +aiowithings==1.0.1 # homeassistant.components.yandex_transport aioymaps==1.2.2 From c9c152d46d297ba455a09c95bab48d1db249373f Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 22 Oct 2023 20:07:23 +0200 Subject: [PATCH 692/968] Bump pyfibaro to 0.7.6 (#102538) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 36cd4c9153f..68763228f82 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.5"] + "requirements": ["pyfibaro==0.7.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index d627e5ad004..1599f2af68d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1709,7 +1709,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.5 +pyfibaro==0.7.6 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27cc3d1a0e6..d0a99f97978 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1285,7 +1285,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.5 +pyfibaro==0.7.6 # homeassistant.components.fido pyfido==2.1.2 From 1fd7378caff29a685d26f2e668e561eba9800a5d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 20:31:17 +0200 Subject: [PATCH 693/968] Remove abstraction in WAQI config flow (#102543) --- homeassistant/components/waqi/config_flow.py | 60 ++++++++++---------- 1 file changed, 31 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index 8404b425678..d23afdf33ee 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -1,7 +1,6 @@ """Config flow for World Air Quality Index (WAQI) integration.""" from __future__ import annotations -from collections.abc import Awaitable, Callable import logging from typing import Any @@ -90,13 +89,10 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_base_step( - self, - step_id: str, - method: Callable[[WAQIClient, dict[str, Any]], Awaitable[WAQIAirQuality]], - data_schema: vol.Schema, - user_input: dict[str, Any] | None = None, + async def async_step_map( + self, user_input: dict[str, Any] | None = None ) -> FlowResult: + """Add measuring station via map.""" errors: dict[str, str] = {} if user_input is not None: async with WAQIClient( @@ -104,7 +100,10 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): ) as waqi_client: waqi_client.authenticate(self.data[CONF_API_KEY]) try: - measuring_station = await method(waqi_client, user_input) + measuring_station = await waqi_client.get_by_coordinates( + user_input[CONF_LOCATION][CONF_LATITUDE], + user_input[CONF_LOCATION][CONF_LONGITUDE], + ) except WAQIConnectionError: errors["base"] = "cannot_connect" except Exception as exc: # pylint: disable=broad-except @@ -113,19 +112,8 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): else: return await self._async_create_entry(measuring_station) return self.async_show_form( - step_id=step_id, data_schema=data_schema, errors=errors - ) - - async def async_step_map( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add measuring station via map.""" - return await self._async_base_step( - CONF_MAP, - lambda waqi_client, data: waqi_client.get_by_coordinates( - data[CONF_LOCATION][CONF_LATITUDE], data[CONF_LOCATION][CONF_LONGITUDE] - ), - self.add_suggested_values_to_schema( + step_id=CONF_MAP, + data_schema=self.add_suggested_values_to_schema( vol.Schema( { vol.Required( @@ -140,26 +128,40 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): } }, ), - user_input, + errors=errors, ) async def async_step_station_number( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Add measuring station via station number.""" - return await self._async_base_step( - CONF_STATION_NUMBER, - lambda waqi_client, data: waqi_client.get_by_station_number( - data[CONF_STATION_NUMBER] - ), - vol.Schema( + errors: dict[str, str] = {} + if user_input is not None: + async with WAQIClient( + session=async_get_clientsession(self.hass) + ) as waqi_client: + waqi_client.authenticate(self.data[CONF_API_KEY]) + try: + measuring_station = await waqi_client.get_by_station_number( + user_input[CONF_STATION_NUMBER] + ) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + else: + return await self._async_create_entry(measuring_station) + return self.async_show_form( + step_id=CONF_STATION_NUMBER, + data_schema=vol.Schema( { vol.Required( CONF_STATION_NUMBER, ): int, } ), - user_input, + errors=errors, ) async def _async_create_entry( From 877410bb9d5cf4784e3d7a1b159ec4cb1caafece Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 20:32:02 +0200 Subject: [PATCH 694/968] Add serial number to Elgato (#102524) Co-authored-by: Franck Nijhof --- homeassistant/components/elgato/entity.py | 1 + tests/components/elgato/snapshots/test_button.ambr | 4 ++-- tests/components/elgato/snapshots/test_light.ambr | 6 +++--- tests/components/elgato/snapshots/test_sensor.ambr | 10 +++++----- tests/components/elgato/snapshots/test_switch.ambr | 4 ++-- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/elgato/entity.py b/homeassistant/components/elgato/entity.py index 4f4c2a9d8e9..1bbd32f5b44 100644 --- a/homeassistant/components/elgato/entity.py +++ b/homeassistant/components/elgato/entity.py @@ -23,6 +23,7 @@ class ElgatoEntity(CoordinatorEntity[ElgatoDataUpdateCoordinator]): super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, coordinator.data.info.serial_number)}, + serial_number=coordinator.data.info.serial_number, manufacturer="Elgato", model=coordinator.data.info.product_name, name=coordinator.data.info.display_name, diff --git a/tests/components/elgato/snapshots/test_button.ambr b/tests/components/elgato/snapshots/test_button.ambr index 134e213db6f..e145c0b82bc 100644 --- a/tests/components/elgato/snapshots/test_button.ambr +++ b/tests/components/elgato/snapshots/test_button.ambr @@ -69,7 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -145,7 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_light.ambr b/tests/components/elgato/snapshots/test_light.ambr index f730015856d..727170128d1 100644 --- a/tests/components/elgato/snapshots/test_light.ambr +++ b/tests/components/elgato/snapshots/test_light.ambr @@ -101,7 +101,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -211,7 +211,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, @@ -321,7 +321,7 @@ 'model': 'Elgato Key Light', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'CN11A1A00001', 'suggested_area': None, 'sw_version': '1.0.3 (192)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_sensor.ambr b/tests/components/elgato/snapshots/test_sensor.ambr index 3afcbc2e106..0322993ef99 100644 --- a/tests/components/elgato/snapshots/test_sensor.ambr +++ b/tests/components/elgato/snapshots/test_sensor.ambr @@ -76,7 +76,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -162,7 +162,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -248,7 +248,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -331,7 +331,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -417,7 +417,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, diff --git a/tests/components/elgato/snapshots/test_switch.ambr b/tests/components/elgato/snapshots/test_switch.ambr index ca34f8d0081..d6b8896d5a2 100644 --- a/tests/components/elgato/snapshots/test_switch.ambr +++ b/tests/components/elgato/snapshots/test_switch.ambr @@ -69,7 +69,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, @@ -145,7 +145,7 @@ 'model': 'Elgato Key Light Mini', 'name': 'Frenck', 'name_by_user': None, - 'serial_number': None, + 'serial_number': 'GW24L1A02987', 'suggested_area': None, 'sw_version': '1.0.4 (229)', 'via_device_id': None, From a77a0ef4a5cc653e35b395e191b553a58d33757b Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Sun, 22 Oct 2023 20:51:27 +0200 Subject: [PATCH 695/968] Add serial number to devolo Home Network (#102546) --- homeassistant/components/devolo_home_network/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 56a1043d126..ff0b2ba2c48 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -53,6 +53,7 @@ class DevoloEntity(Entity): manufacturer="devolo", model=device.product, name=entry.title, + serial_number=device.serial_number, sw_version=device.firmware_version, ) self._attr_translation_key = self.entity_description.key From b416c8fbf6b3952c3a0bc6330694ca29f55fe537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 22 Oct 2023 20:52:17 +0200 Subject: [PATCH 696/968] Update aioairzone-cloud to v0.3.0 (#102540) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index bc5bf1ee875..a3c0f5e7dc0 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.2.8"] + "requirements": ["aioairzone-cloud==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1599f2af68d..83ce2b239f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.8 +aioairzone-cloud==0.3.0 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0a99f97978..dae256ee389 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.2.8 +aioairzone-cloud==0.3.0 # homeassistant.components.airzone aioairzone==0.6.9 From 6b618fc95ab6a729fe2d66affbbb3f197fd4eeea Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 21:02:39 +0200 Subject: [PATCH 697/968] Add entity translations to SolarEdge (#102295) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- homeassistant/components/solaredge/sensor.py | 42 ++++++------ .../components/solaredge/strings.json | 67 +++++++++++++++++++ 2 files changed, 88 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index ca998493237..5e298ae2a6f 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -51,7 +51,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="lifetime_energy", json_key="lifeTimeData", - name="Lifetime energy", + translation_key="lifetime_energy", icon="mdi:solar-power", state_class=SensorStateClass.TOTAL, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -60,7 +60,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_this_year", json_key="lastYearData", - name="Energy this year", + translation_key="energy_this_year", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -69,7 +69,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_this_month", json_key="lastMonthData", - name="Energy this month", + translation_key="energy_this_month", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -78,7 +78,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="energy_today", json_key="lastDayData", - name="Energy today", + translation_key="energy_today", entity_registry_enabled_default=False, icon="mdi:solar-power", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -87,7 +87,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="current_power", json_key="currentPower", - name="Current Power", + translation_key="current_power", icon="mdi:solar-power", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, @@ -96,71 +96,71 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="site_details", json_key="status", - name="Site details", + translation_key="site_details", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="meters", json_key="meters", - name="Meters", + translation_key="meters", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="sensors", json_key="sensors", - name="Sensors", + translation_key="sensors", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="gateways", json_key="gateways", - name="Gateways", + translation_key="gateways", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="batteries", json_key="batteries", - name="Batteries", + translation_key="batteries", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="inverters", json_key="inverters", - name="Inverters", + translation_key="inverters", entity_registry_enabled_default=False, ), SolarEdgeSensorEntityDescription( key="power_consumption", json_key="LOAD", - name="Power Consumption", + translation_key="power_consumption", entity_registry_enabled_default=False, icon="mdi:flash", ), SolarEdgeSensorEntityDescription( key="solar_power", json_key="PV", - name="Solar Power", + translation_key="solar_power", entity_registry_enabled_default=False, icon="mdi:solar-power", ), SolarEdgeSensorEntityDescription( key="grid_power", json_key="GRID", - name="Grid Power", + translation_key="grid_power", entity_registry_enabled_default=False, icon="mdi:power-plug", ), SolarEdgeSensorEntityDescription( key="storage_power", json_key="STORAGE", - name="Storage Power", + translation_key="storage_power", entity_registry_enabled_default=False, icon="mdi:car-battery", ), SolarEdgeSensorEntityDescription( key="purchased_energy", json_key="Purchased", - name="Imported Energy", + translation_key="purchased_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -169,7 +169,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="production_energy", json_key="Production", - name="Production Energy", + translation_key="production_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -178,7 +178,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="consumption_energy", json_key="Consumption", - name="Consumption Energy", + translation_key="consumption_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -187,7 +187,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="selfconsumption_energy", json_key="SelfConsumption", - name="SelfConsumption Energy", + translation_key="selfconsumption_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -196,7 +196,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="feedin_energy", json_key="FeedIn", - name="Exported Energy", + translation_key="feedin_energy", entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, @@ -205,7 +205,7 @@ SENSOR_TYPES = [ SolarEdgeSensorEntityDescription( key="storage_level", json_key="STORAGE", - name="Storage Level", + translation_key="storage_level", entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json index b6f258b0dc8..2b626987546 100644 --- a/homeassistant/components/solaredge/strings.json +++ b/homeassistant/components/solaredge/strings.json @@ -19,5 +19,72 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "sensor": { + "lifetime_energy": { + "name": "Lifetime energy" + }, + "energy_this_year": { + "name": "Energy this year" + }, + "energy_this_month": { + "name": "Energy this month" + }, + "energy_today": { + "name": "Energy today" + }, + "current_power": { + "name": "Current power" + }, + "site_details": { + "name": "Site details" + }, + "meters": { + "name": "Meters" + }, + "sensors": { + "name": "Sensors" + }, + "gateways": { + "name": "Gateways" + }, + "batteries": { + "name": "Batteries" + }, + "inverters": { + "name": "Inverters" + }, + "power_consumption": { + "name": "Power consumption" + }, + "solar_power": { + "name": "Solar power" + }, + "grid_power": { + "name": "Grid power" + }, + "storage_power": { + "name": "Stored power" + }, + "purchased_energy": { + "name": "Imported energy" + }, + "production_energy": { + "name": "Produced energy" + }, + "consumption_energy": { + "name": "Consumed energy" + }, + "selfconsumption_energy": { + "name": "Self-consumed energy" + }, + "feedin_energy": { + "name": "Exported energy" + }, + "storage_level": { + "name": "Storage level" + } + } } } From 4bf0d6e536bf343cafe2173f61715b30a4d3ecf6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 09:20:49 -1000 Subject: [PATCH 698/968] Bump aioesphomeapi to 18.0.10 (#102545) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index f59a09b3d6b..ae52af971ed 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.9", + "aioesphomeapi==18.0.10", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 83ce2b239f5..c784e9dad7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.9 +aioesphomeapi==18.0.10 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dae256ee389..3c9cc360ecd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.9 +aioesphomeapi==18.0.10 # homeassistant.components.flo aioflo==2021.11.0 From 3f88c518a5ee63ae169a126c4ec977e108e0426c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 21:39:01 +0200 Subject: [PATCH 699/968] Use translated name for entity id for Picnic (#97230) --- homeassistant/components/picnic/sensor.py | 2 - tests/components/picnic/test_sensor.py | 188 +++++++++++++++------- 2 files changed, 128 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index fb4e756b1be..e7a69e0bf02 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -254,8 +254,6 @@ class PicnicSensor(SensorEntity, CoordinatorEntity): super().__init__(coordinator) self.entity_description = description - self.entity_id = f"sensor.picnic_{description.key}" - self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 6d5a56499b9..cae10320fb9 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -9,7 +9,7 @@ import requests from homeassistant import config_entries from homeassistant.components.picnic import const -from homeassistant.components.picnic.const import CONF_COUNTRY_CODE +from homeassistant.components.picnic.const import CONF_COUNTRY_CODE, DOMAIN from homeassistant.components.picnic.sensor import SENSOR_TYPES from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( @@ -17,6 +17,7 @@ from homeassistant.const import ( CURRENCY_EURO, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import dt as dt_util @@ -168,8 +169,11 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): """Enable all sensors of the Picnic integration.""" # Enable the sensors for sensor_type in SENSOR_KEYS: + entry = self.entity_registry.async_get_or_create( + Platform.SENSOR, DOMAIN, f"{self.config_entry.unique_id}.{sensor_type}" + ) updated_entry = self.entity_registry.async_update_entity( - f"sensor.picnic_{sensor_type}", disabled_by=None + entry.entity_id, disabled_by=None ) assert updated_entry.disabled is False await self.hass.async_block_till_done() @@ -197,76 +201,86 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert that sensors are not set up assert ( - self.hass.states.get("sensor.picnic_selected_slot_max_order_time") is None + self.hass.states.get("sensor.mock_title_max_order_time_of_selected_slot") + is None + ) + assert self.hass.states.get("sensor.mock_title_status_of_last_order") is None + assert ( + self.hass.states.get("sensor.mock_title_total_price_of_last_order") is None ) - assert self.hass.states.get("sensor.picnic_last_order_status") is None - assert self.hass.states.get("sensor.picnic_last_order_total_price") is None async def test_sensors_setup(self): """Test the default sensor setup behaviour.""" await self._setup_platform(use_default_responses=True) - self._assert_sensor("sensor.picnic_cart_items_count", "10") + self._assert_sensor("sensor.mock_title_cart_items_count", "10") self._assert_sensor( - "sensor.picnic_cart_total_price", "25.35", unit=CURRENCY_EURO + "sensor.mock_title_cart_total_price", + "25.35", + unit=CURRENCY_EURO, ) self._assert_sensor( - "sensor.picnic_selected_slot_start", + "sensor.mock_title_start_of_selected_slot", "2021-03-03T13:45:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_selected_slot_end", + "sensor.mock_title_end_of_selected_slot", "2021-03-03T14:45:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_selected_slot_max_order_time", + "sensor.mock_title_max_order_time_of_selected_slot", "2021-03-02T21:00:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) - self._assert_sensor("sensor.picnic_selected_slot_min_order_value", "35.0") self._assert_sensor( - "sensor.picnic_last_order_slot_start", + "sensor.mock_title_minimum_order_value_for_selected_slot", + "35.0", + ) + self._assert_sensor( + "sensor.mock_title_start_of_last_order_s_slot", "2021-02-26T19:15:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_slot_end", + "sensor.mock_title_end_of_last_order_s_slot", "2021-02-26T20:15:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) - self._assert_sensor("sensor.picnic_last_order_status", "COMPLETED") + self._assert_sensor("sensor.mock_title_status_of_last_order", "COMPLETED") self._assert_sensor( - "sensor.picnic_last_order_max_order_time", + "sensor.mock_title_max_order_time_of_last_order", "2021-02-25T21:00:00+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_delivery_time", + "sensor.mock_title_last_order_delivery_time", "2021-02-26T19:54:05+00:00", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_last_order_total_price", "41.33", unit=CURRENCY_EURO + "sensor.mock_title_total_price_of_last_order", + "41.33", + unit=CURRENCY_EURO, ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", + "sensor.mock_title_expected_start_of_next_delivery", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", + "sensor.mock_title_expected_end_of_next_delivery", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", + "sensor.mock_title_start_of_next_delivery_s_slot", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", + "sensor.mock_title_end_of_next_delivery_s_slot", "unknown", cls=SensorDeviceClass.TIMESTAMP, ) @@ -275,13 +289,22 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): """Test that some sensors are disabled by default.""" await self._setup_platform(use_default_responses=True, enable_all_sensors=False) - self._assert_sensor("sensor.picnic_cart_items_count", disabled=True) - self._assert_sensor("sensor.picnic_last_order_slot_start", disabled=True) - self._assert_sensor("sensor.picnic_last_order_slot_end", disabled=True) - self._assert_sensor("sensor.picnic_last_order_status", disabled=True) - self._assert_sensor("sensor.picnic_last_order_total_price", disabled=True) - self._assert_sensor("sensor.picnic_next_delivery_slot_start", disabled=True) - self._assert_sensor("sensor.picnic_next_delivery_slot_end", disabled=True) + self._assert_sensor("sensor.mock_title_cart_items_count", disabled=True) + self._assert_sensor( + "sensor.mock_title_start_of_last_order_s_slot", disabled=True + ) + self._assert_sensor("sensor.mock_title_end_of_last_order_s_slot", disabled=True) + self._assert_sensor("sensor.mock_title_status_of_last_order", disabled=True) + self._assert_sensor( + "sensor.mock_title_total_price_of_last_order", disabled=True + ) + self._assert_sensor( + "sensor.mock_title_start_of_next_delivery_s_slot", + disabled=True, + ) + self._assert_sensor( + "sensor.mock_title_end_of_next_delivery_s_slot", disabled=True + ) async def test_sensors_no_selected_time_slot(self): """Test sensor states with no explicit selected time slot.""" @@ -299,11 +322,15 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() # Assert sensors are unknown - self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_selected_slot_max_order_time", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_start_of_selected_slot", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_end_of_selected_slot", STATE_UNKNOWN) self._assert_sensor( - "sensor.picnic_selected_slot_min_order_value", STATE_UNKNOWN + "sensor.mock_title_max_order_time_of_selected_slot", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_minimum_order_value_for_selected_slot", + STATE_UNKNOWN, ) async def test_next_delivery_sensors(self): @@ -321,18 +348,22 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() # Assert delivery time is not available, but eta is - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) + self._assert_sensor("sensor.mock_title_last_order_delivery_time", STATE_UNKNOWN) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2021-02-26T19:54:00+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2021-02-26T19:54:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2021-02-26T20:14:00+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2021-02-26T20:14:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", "2021-02-26T19:15:00+00:00" + "sensor.mock_title_start_of_next_delivery_s_slot", + "2021-02-26T19:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", "2021-02-26T20:15:00+00:00" + "sensor.mock_title_end_of_next_delivery_s_slot", + "2021-02-26T20:15:00+00:00", ) async def test_sensors_eta_date_malformed(self): @@ -352,8 +383,14 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._coordinator.async_refresh() # Assert eta times are not available due to malformed date strings - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_use_detailed_eta_if_available(self): """Test sensor states when last order is not yet delivered.""" @@ -378,10 +415,12 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): delivery_response["delivery_id"] ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2021-03-05T10:19:20+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2021-03-05T10:19:20+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2021-03-05T10:39:20+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2021-03-05T10:39:20+00:00", ) async def test_sensors_no_data(self): @@ -398,21 +437,35 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert all default-enabled sensors have STATE_UNAVAILABLE because the last update failed assert self._coordinator.last_update_success is False - self._assert_sensor("sensor.picnic_cart_total_price", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_selected_slot_start", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_selected_slot_end", STATE_UNAVAILABLE) + self._assert_sensor("sensor.mock_title_cart_total_price", STATE_UNAVAILABLE) self._assert_sensor( - "sensor.picnic_selected_slot_max_order_time", STATE_UNAVAILABLE + "sensor.mock_title_start_of_selected_slot", STATE_UNAVAILABLE + ) + self._assert_sensor("sensor.mock_title_end_of_selected_slot", STATE_UNAVAILABLE) + self._assert_sensor( + "sensor.mock_title_max_order_time_of_selected_slot", + STATE_UNAVAILABLE, ) self._assert_sensor( - "sensor.picnic_selected_slot_min_order_value", STATE_UNAVAILABLE + "sensor.mock_title_minimum_order_value_for_selected_slot", + STATE_UNAVAILABLE, ) self._assert_sensor( - "sensor.picnic_last_order_max_order_time", STATE_UNAVAILABLE + "sensor.mock_title_max_order_time_of_last_order", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_last_order_delivery_time", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNAVAILABLE, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNAVAILABLE, ) - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNAVAILABLE) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNAVAILABLE) async def test_sensors_malformed_delivery_data(self): """Test sensor states when the delivery api returns not a list.""" @@ -425,10 +478,19 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Assert all last-order sensors have STATE_UNAVAILABLE because the delivery info fetch failed assert self._coordinator.last_update_success is True - self._assert_sensor("sensor.picnic_last_order_max_order_time", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_last_order_delivery_time", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_start", STATE_UNKNOWN) - self._assert_sensor("sensor.picnic_next_delivery_eta_end", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_max_order_time_of_last_order", + STATE_UNKNOWN, + ) + self._assert_sensor("sensor.mock_title_last_order_delivery_time", STATE_UNKNOWN) + self._assert_sensor( + "sensor.mock_title_expected_start_of_next_delivery", + STATE_UNKNOWN, + ) + self._assert_sensor( + "sensor.mock_title_expected_end_of_next_delivery", + STATE_UNKNOWN, + ) async def test_sensors_malformed_response(self): """Test coordinator update fails when API yields ValueError.""" @@ -474,22 +536,28 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): await self._setup_platform() self._assert_sensor( - "sensor.picnic_last_order_slot_start", "2022-03-08T12:15:00+00:00" + "sensor.mock_title_start_of_last_order_s_slot", + "2022-03-08T12:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_last_order_slot_end", "2022-03-08T13:15:00+00:00" + "sensor.mock_title_end_of_last_order_s_slot", + "2022-03-08T13:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_start", "2022-03-01T08:15:00+00:00" + "sensor.mock_title_start_of_next_delivery_s_slot", + "2022-03-01T08:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_slot_end", "2022-03-01T09:15:00+00:00" + "sensor.mock_title_end_of_next_delivery_s_slot", + "2022-03-01T09:15:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_start", "2022-03-01T08:30:00+00:00" + "sensor.mock_title_expected_start_of_next_delivery", + "2022-03-01T08:30:00+00:00", ) self._assert_sensor( - "sensor.picnic_next_delivery_eta_end", "2022-03-01T08:45:00+00:00" + "sensor.mock_title_expected_end_of_next_delivery", + "2022-03-01T08:45:00+00:00", ) async def test_device_registry_entry(self): From e3b238861d886bf824741d96025d939249e71878 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 21:58:30 +0200 Subject: [PATCH 700/968] Clean up withings tests (#102548) --- tests/components/withings/__init__.py | 22 +- tests/components/withings/conftest.py | 29 +- .../components/withings/fixtures/devices.json | 13 + .../withings/fixtures/get_device.json | 15 - .../withings/fixtures/get_meas.json | 313 ------------------ .../withings/fixtures/get_sleep.json | 201 ----------- .../withings/fixtures/measurements.json | 307 +++++++++++++++++ .../{get_meas_1.json => measurements_1.json} | 0 .../withings/fixtures/notifications.json | 20 ++ .../withings/fixtures/notify_list.json | 22 -- .../withings/fixtures/sleep_summaries.json | 197 +++++++++++ tests/components/withings/test_init.py | 4 - tests/components/withings/test_sensor.py | 47 +-- 13 files changed, 585 insertions(+), 605 deletions(-) create mode 100644 tests/components/withings/fixtures/devices.json delete mode 100644 tests/components/withings/fixtures/get_device.json delete mode 100644 tests/components/withings/fixtures/get_meas.json delete mode 100644 tests/components/withings/fixtures/get_sleep.json create mode 100644 tests/components/withings/fixtures/measurements.json rename tests/components/withings/fixtures/{get_meas_1.json => measurements_1.json} (100%) create mode 100644 tests/components/withings/fixtures/notifications.json delete mode 100644 tests/components/withings/fixtures/notify_list.json create mode 100644 tests/components/withings/fixtures/sleep_summaries.json diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 9693d21f162..2425a5bd600 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,13 +5,19 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient +from aiowithings import Goals, MeasurementGroup from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + load_json_array_fixture, + load_json_object_fixture, +) @dataclass @@ -64,3 +70,17 @@ async def prepare_webhook_setup( freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() + + +def load_goals_fixture(fixture: str = "withings/goals.json") -> Goals: + """Return goals from fixture.""" + goals_json = load_json_object_fixture(fixture) + return Goals.from_api(goals_json) + + +def load_measurements_fixture( + fixture: str = "withings/measurements.json", +) -> list[MeasurementGroup]: + """Return measurement from fixture.""" + meas_json = load_json_array_fixture(fixture) + return [MeasurementGroup.from_api(measurement) for measurement in meas_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 0131feba943..8a824d84917 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -3,7 +3,7 @@ from datetime import timedelta import time from unittest.mock import AsyncMock, patch -from aiowithings import Device, Goals, MeasurementGroup, SleepSummary, WithingsClient +from aiowithings import Device, SleepSummary, WithingsClient from aiowithings.models import NotificationConfiguration import pytest @@ -15,7 +15,8 @@ from homeassistant.components.withings.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture +from tests.common import MockConfigEntry, load_json_array_fixture +from tests.components.withings import load_goals_fixture, load_measurements_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -128,32 +129,24 @@ def polling_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: def mock_withings(): """Mock withings.""" - devices_json = load_json_object_fixture("withings/get_device.json") - devices = [Device.from_api(device) for device in devices_json["devices"]] + devices_json = load_json_array_fixture("withings/devices.json") + devices = [Device.from_api(device) for device in devices_json] - meas_json = load_json_object_fixture("withings/get_meas.json") - measurement_groups = [ - MeasurementGroup.from_api(measurement) - for measurement in meas_json["measuregrps"] - ] + measurement_groups = load_measurements_fixture("withings/measurements.json") - sleep_json = load_json_object_fixture("withings/get_sleep.json") + sleep_json = load_json_array_fixture("withings/sleep_summaries.json") sleep_summaries = [ - SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json["series"] + SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json ] - notification_json = load_json_object_fixture("withings/notify_list.json") + notification_json = load_json_array_fixture("withings/notifications.json") notifications = [ - NotificationConfiguration.from_api(not_conf) - for not_conf in notification_json["profiles"] + NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] - goals_json = load_json_object_fixture("withings/goals.json") - goals = Goals.from_api(goals_json) - mock = AsyncMock(spec=WithingsClient) mock.get_devices.return_value = devices - mock.get_goals.return_value = goals + mock.get_goals.return_value = load_goals_fixture("withings/goals.json") mock.get_measurement_in_period.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups mock.get_sleep_summary_since.return_value = sleep_summaries diff --git a/tests/components/withings/fixtures/devices.json b/tests/components/withings/fixtures/devices.json new file mode 100644 index 00000000000..9a2b7b81cf4 --- /dev/null +++ b/tests/components/withings/fixtures/devices.json @@ -0,0 +1,13 @@ +[ + { + "type": "Scale", + "battery": "high", + "model": "Body+", + "model_id": 5, + "timezone": "Europe/Amsterdam", + "first_session_date": null, + "last_session_date": 1693867179, + "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", + "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" + } +] diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json deleted file mode 100644 index 64bac3d4a19..00000000000 --- a/tests/components/withings/fixtures/get_device.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "devices": [ - { - "type": "Scale", - "battery": "high", - "model": "Body+", - "model_id": 5, - "timezone": "Europe/Amsterdam", - "first_session_date": null, - "last_session_date": 1693867179, - "deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d", - "hash_deviceid": "f998be4b9ccc9e136fd8cd8e8e344c31ec3b271d" - } - ] -} diff --git a/tests/components/withings/fixtures/get_meas.json b/tests/components/withings/fixtures/get_meas.json deleted file mode 100644 index d473b61c274..00000000000 --- a/tests/components/withings/fixtures/get_meas.json +++ /dev/null @@ -1,313 +0,0 @@ -{ - "more": false, - "timezone": "UTC", - "updatetime": 1564617600, - "offset": 0, - "measuregrps": [ - { - "grpid": 1, - "attrib": 0, - "date": 1564660800, - "created": 1564660800, - "modified": 1564660800, - "category": 1, - "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "measures": [ - { - "type": 1, - "unit": 0, - "value": 70 - }, - { - "type": 8, - "unit": 0, - "value": 5 - }, - { - "type": 5, - "unit": 0, - "value": 60 - }, - { - "type": 76, - "unit": 0, - "value": 50 - }, - { - "type": 88, - "unit": 0, - "value": 10 - }, - { - "type": 4, - "unit": 0, - "value": 2 - }, - { - "type": 12, - "unit": 0, - "value": 40 - }, - { - "type": 71, - "unit": 0, - "value": 40 - }, - { - "type": 73, - "unit": 0, - "value": 20 - }, - { - "type": 6, - "unit": -3, - "value": 70 - }, - { - "type": 9, - "unit": 0, - "value": 70 - }, - { - "type": 10, - "unit": 0, - "value": 100 - }, - { - "type": 11, - "unit": 0, - "value": 60 - }, - { - "type": 54, - "unit": -2, - "value": 95 - }, - { - "type": 77, - "unit": -2, - "value": 95 - }, - { - "type": 91, - "unit": 0, - "value": 100 - }, - { - "type": 123, - "unit": 0, - "value": 100 - }, - { - "type": 155, - "unit": 0, - "value": 100 - }, - { - "type": 168, - "unit": 0, - "value": 100 - }, - { - "type": 169, - "unit": 0, - "value": 100 - } - ], - "modelid": 45, - "model": "BPM Connect", - "comment": null - }, - { - "grpid": 1, - "attrib": 0, - "date": 1564657200, - "created": 1564657200, - "modified": 1564657200, - "category": 1, - "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "measures": [ - { - "type": 1, - "unit": 0, - "value": 71 - }, - { - "type": 8, - "unit": 0, - "value": 51 - }, - { - "type": 5, - "unit": 0, - "value": 61 - }, - { - "type": 76, - "unit": 0, - "value": 51 - }, - { - "type": 88, - "unit": 0, - "value": 11 - }, - { - "type": 4, - "unit": 0, - "value": 21 - }, - { - "type": 12, - "unit": 0, - "value": 41 - }, - { - "type": 71, - "unit": 0, - "value": 41 - }, - { - "type": 73, - "unit": 0, - "value": 21 - }, - { - "type": 6, - "unit": -3, - "value": 71 - }, - { - "type": 9, - "unit": 0, - "value": 71 - }, - { - "type": 10, - "unit": 0, - "value": 101 - }, - { - "type": 11, - "unit": 0, - "value": 61 - }, - { - "type": 54, - "unit": -2, - "value": 96 - }, - { - "type": 77, - "unit": -2, - "value": 96 - }, - { - "type": 91, - "unit": 0, - "value": 101 - } - ], - "modelid": 45, - "model": "BPM Connect", - "comment": null - }, - { - "grpid": 1, - "attrib": 1, - "date": 1564664400, - "created": 1564664400, - "modified": 1564664400, - "category": 1, - "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", - "measures": [ - { - "type": 1, - "unit": 0, - "value": 71 - }, - { - "type": 8, - "unit": 0, - "value": 4 - }, - { - "type": 5, - "unit": 0, - "value": 40 - }, - { - "type": 76, - "unit": 0, - "value": 51 - }, - { - "type": 88, - "unit": 0, - "value": 11 - }, - { - "type": 4, - "unit": 0, - "value": 201 - }, - { - "type": 12, - "unit": 0, - "value": 41 - }, - { - "type": 71, - "unit": 0, - "value": 34 - }, - { - "type": 73, - "unit": 0, - "value": 21 - }, - { - "type": 6, - "unit": -3, - "value": 71 - }, - { - "type": 9, - "unit": 0, - "value": 71 - }, - { - "type": 10, - "unit": 0, - "value": 101 - }, - { - "type": 11, - "unit": 0, - "value": 61 - }, - { - "type": 54, - "unit": -2, - "value": 98 - }, - { - "type": 77, - "unit": -2, - "value": 96 - }, - { - "type": 91, - "unit": 0, - "value": 102 - } - ], - "modelid": 45, - "model": "BPM Connect", - "comment": null - } - ] -} diff --git a/tests/components/withings/fixtures/get_sleep.json b/tests/components/withings/fixtures/get_sleep.json deleted file mode 100644 index 29ed3df3fd3..00000000000 --- a/tests/components/withings/fixtures/get_sleep.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "more": false, - "offset": 0, - "series": [ - { - "id": 2081804182, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618691453, - "enddate": 1618713173, - "date": "2021-04-18", - "data": { - "wakeupduration": 3060, - "wakeupcount": 1, - "durationtosleep": 540, - "remsleepduration": 2400, - "durationtowakeup": 1140, - "total_sleep_time": 18660, - "sleep_efficiency": 0.86, - "sleep_latency": 540, - "wakeup_latency": 1140, - "waso": 1380, - "nb_rem_episodes": 1, - "out_of_bed_count": 0, - "lightsleepduration": 10440, - "deepsleepduration": 5820, - "hr_average": 103, - "hr_min": 70, - "hr_max": 120, - "rr_average": 14, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": 9, - "snoring": 1080, - "snoringepisodecount": 18, - "sleep_score": 37, - "apnea_hypopnea_index": 9 - }, - "created": 1620237476, - "modified": 1620237476 - }, - { - "id": 2081804265, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618605055, - "enddate": 1618636975, - "date": "2021-04-17", - "data": { - "wakeupduration": 2520, - "wakeupcount": 3, - "durationtosleep": 900, - "remsleepduration": 6840, - "durationtowakeup": 420, - "total_sleep_time": 26880, - "sleep_efficiency": 0.91, - "sleep_latency": 900, - "wakeup_latency": 420, - "waso": 1200, - "nb_rem_episodes": 2, - "out_of_bed_count": 0, - "lightsleepduration": 12840, - "deepsleepduration": 7200, - "hr_average": 85, - "hr_min": 50, - "hr_max": 120, - "rr_average": 16, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": 14, - "snoring": 1140, - "snoringepisodecount": 19, - "sleep_score": 90, - "apnea_hypopnea_index": 14 - }, - "created": 1620237480, - "modified": 1620237479 - }, - { - "id": 2081804358, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618518658, - "enddate": 1618548058, - "date": "2021-04-16", - "data": { - "wakeupduration": 4080, - "wakeupcount": 1, - "durationtosleep": 840, - "remsleepduration": 2040, - "durationtowakeup": 1560, - "total_sleep_time": 16860, - "sleep_efficiency": 0.81, - "sleep_latency": 840, - "wakeup_latency": 1560, - "waso": 1680, - "nb_rem_episodes": 2, - "out_of_bed_count": 0, - "lightsleepduration": 11100, - "deepsleepduration": 3720, - "hr_average": 65, - "hr_min": 50, - "hr_max": 91, - "rr_average": 14, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": -1, - "snoring": 1020, - "snoringepisodecount": 17, - "sleep_score": 20, - "apnea_hypopnea_index": -1 - }, - "created": 1620237484, - "modified": 1620237484 - }, - { - "id": 2081804405, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618432203, - "enddate": 1618453143, - "date": "2021-04-15", - "data": { - "wakeupduration": 4080, - "wakeupcount": 1, - "durationtosleep": 840, - "remsleepduration": 2040, - "durationtowakeup": 1560, - "total_sleep_time": 16860, - "sleep_efficiency": 0.81, - "sleep_latency": 840, - "wakeup_latency": 1560, - "waso": 1680, - "nb_rem_episodes": 2, - "out_of_bed_count": 0, - "lightsleepduration": 11100, - "deepsleepduration": 3720, - "hr_average": 65, - "hr_min": 50, - "hr_max": 91, - "rr_average": 14, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": -1, - "snoring": 1020, - "snoringepisodecount": 17, - "sleep_score": 20, - "apnea_hypopnea_index": -1 - }, - "created": 1620237486, - "modified": 1620237486 - }, - { - "id": 2081804490, - "timezone": "Europe/Paris", - "model": 32, - "model_id": 63, - "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", - "startdate": 1618345805, - "enddate": 1618373504, - "date": "2021-04-14", - "data": { - "wakeupduration": 3600, - "wakeupcount": 2, - "durationtosleep": 780, - "remsleepduration": 3960, - "durationtowakeup": 300, - "total_sleep_time": 22680, - "sleep_efficiency": 0.86, - "sleep_latency": 780, - "wakeup_latency": 300, - "waso": 3939, - "nb_rem_episodes": 4, - "out_of_bed_count": 3, - "lightsleepduration": 12960, - "deepsleepduration": 5760, - "hr_average": 98, - "hr_min": 70, - "hr_max": 120, - "rr_average": 13, - "rr_min": 10, - "rr_max": 20, - "breathing_disturbances_intensity": 29, - "snoring": 960, - "snoringepisodecount": 16, - "sleep_score": 62, - "apnea_hypopnea_index": 29 - }, - "created": 1620237490, - "modified": 1620237489 - } - ] -} diff --git a/tests/components/withings/fixtures/measurements.json b/tests/components/withings/fixtures/measurements.json new file mode 100644 index 00000000000..3ed59a7c3f4 --- /dev/null +++ b/tests/components/withings/fixtures/measurements.json @@ -0,0 +1,307 @@ +[ + { + "grpid": 1, + "attrib": 0, + "date": 1564660800, + "created": 1564660800, + "modified": 1564660800, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 70 + }, + { + "type": 8, + "unit": 0, + "value": 5 + }, + { + "type": 5, + "unit": 0, + "value": 60 + }, + { + "type": 76, + "unit": 0, + "value": 50 + }, + { + "type": 88, + "unit": 0, + "value": 10 + }, + { + "type": 4, + "unit": 0, + "value": 2 + }, + { + "type": 12, + "unit": 0, + "value": 40 + }, + { + "type": 71, + "unit": 0, + "value": 40 + }, + { + "type": 73, + "unit": 0, + "value": 20 + }, + { + "type": 6, + "unit": -3, + "value": 70 + }, + { + "type": 9, + "unit": 0, + "value": 70 + }, + { + "type": 10, + "unit": 0, + "value": 100 + }, + { + "type": 11, + "unit": 0, + "value": 60 + }, + { + "type": 54, + "unit": -2, + "value": 95 + }, + { + "type": 77, + "unit": -2, + "value": 95 + }, + { + "type": 91, + "unit": 0, + "value": 100 + }, + { + "type": 123, + "unit": 0, + "value": 100 + }, + { + "type": 155, + "unit": 0, + "value": 100 + }, + { + "type": 168, + "unit": 0, + "value": 100 + }, + { + "type": 169, + "unit": 0, + "value": 100 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + }, + { + "grpid": 1, + "attrib": 0, + "date": 1564657200, + "created": 1564657200, + "modified": 1564657200, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 51 + }, + { + "type": 5, + "unit": 0, + "value": 61 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 21 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 41 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 96 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 101 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + }, + { + "grpid": 1, + "attrib": 1, + "date": 1564664400, + "created": 1564664400, + "modified": 1564664400, + "category": 1, + "deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "hash_deviceid": "91a7e556c2022ef54dca6e07a853c3193734d148", + "measures": [ + { + "type": 1, + "unit": 0, + "value": 71 + }, + { + "type": 8, + "unit": 0, + "value": 4 + }, + { + "type": 5, + "unit": 0, + "value": 40 + }, + { + "type": 76, + "unit": 0, + "value": 51 + }, + { + "type": 88, + "unit": 0, + "value": 11 + }, + { + "type": 4, + "unit": 0, + "value": 201 + }, + { + "type": 12, + "unit": 0, + "value": 41 + }, + { + "type": 71, + "unit": 0, + "value": 34 + }, + { + "type": 73, + "unit": 0, + "value": 21 + }, + { + "type": 6, + "unit": -3, + "value": 71 + }, + { + "type": 9, + "unit": 0, + "value": 71 + }, + { + "type": 10, + "unit": 0, + "value": 101 + }, + { + "type": 11, + "unit": 0, + "value": 61 + }, + { + "type": 54, + "unit": -2, + "value": 98 + }, + { + "type": 77, + "unit": -2, + "value": 96 + }, + { + "type": 91, + "unit": 0, + "value": 102 + } + ], + "modelid": 45, + "model": "BPM Connect", + "comment": null + } +] diff --git a/tests/components/withings/fixtures/get_meas_1.json b/tests/components/withings/fixtures/measurements_1.json similarity index 100% rename from tests/components/withings/fixtures/get_meas_1.json rename to tests/components/withings/fixtures/measurements_1.json diff --git a/tests/components/withings/fixtures/notifications.json b/tests/components/withings/fixtures/notifications.json new file mode 100644 index 00000000000..8f4d49fde49 --- /dev/null +++ b/tests/components/withings/fixtures/notifications.json @@ -0,0 +1,20 @@ +[ + { + "appli": 50, + "callbackurl": "https://not.my.callback/url", + "expires": 2147483647, + "comment": null + }, + { + "appli": 50, + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + } +] diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json deleted file mode 100644 index ef7a99857e4..00000000000 --- a/tests/components/withings/fixtures/notify_list.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "profiles": [ - { - "appli": 50, - "callbackurl": "https://not.my.callback/url", - "expires": 2147483647, - "comment": null - }, - { - "appli": 50, - "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", - "expires": 2147483647, - "comment": null - }, - { - "appli": 51, - "callbackurl": "https://example.com/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", - "expires": 2147483647, - "comment": null - } - ] -} diff --git a/tests/components/withings/fixtures/sleep_summaries.json b/tests/components/withings/fixtures/sleep_summaries.json new file mode 100644 index 00000000000..1bcfcfcc1d2 --- /dev/null +++ b/tests/components/withings/fixtures/sleep_summaries.json @@ -0,0 +1,197 @@ +[ + { + "id": 2081804182, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618691453, + "enddate": 1618713173, + "date": "2021-04-18", + "data": { + "wakeupduration": 3060, + "wakeupcount": 1, + "durationtosleep": 540, + "remsleepduration": 2400, + "durationtowakeup": 1140, + "total_sleep_time": 18660, + "sleep_efficiency": 0.86, + "sleep_latency": 540, + "wakeup_latency": 1140, + "waso": 1380, + "nb_rem_episodes": 1, + "out_of_bed_count": 0, + "lightsleepduration": 10440, + "deepsleepduration": 5820, + "hr_average": 103, + "hr_min": 70, + "hr_max": 120, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 9, + "snoring": 1080, + "snoringepisodecount": 18, + "sleep_score": 37, + "apnea_hypopnea_index": 9 + }, + "created": 1620237476, + "modified": 1620237476 + }, + { + "id": 2081804265, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618605055, + "enddate": 1618636975, + "date": "2021-04-17", + "data": { + "wakeupduration": 2520, + "wakeupcount": 3, + "durationtosleep": 900, + "remsleepduration": 6840, + "durationtowakeup": 420, + "total_sleep_time": 26880, + "sleep_efficiency": 0.91, + "sleep_latency": 900, + "wakeup_latency": 420, + "waso": 1200, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 12840, + "deepsleepduration": 7200, + "hr_average": 85, + "hr_min": 50, + "hr_max": 120, + "rr_average": 16, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 14, + "snoring": 1140, + "snoringepisodecount": 19, + "sleep_score": 90, + "apnea_hypopnea_index": 14 + }, + "created": 1620237480, + "modified": 1620237479 + }, + { + "id": 2081804358, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618518658, + "enddate": 1618548058, + "date": "2021-04-16", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237484, + "modified": 1620237484 + }, + { + "id": 2081804405, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618432203, + "enddate": 1618453143, + "date": "2021-04-15", + "data": { + "wakeupduration": 4080, + "wakeupcount": 1, + "durationtosleep": 840, + "remsleepduration": 2040, + "durationtowakeup": 1560, + "total_sleep_time": 16860, + "sleep_efficiency": 0.81, + "sleep_latency": 840, + "wakeup_latency": 1560, + "waso": 1680, + "nb_rem_episodes": 2, + "out_of_bed_count": 0, + "lightsleepduration": 11100, + "deepsleepduration": 3720, + "hr_average": 65, + "hr_min": 50, + "hr_max": 91, + "rr_average": 14, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": -1, + "snoring": 1020, + "snoringepisodecount": 17, + "sleep_score": 20, + "apnea_hypopnea_index": -1 + }, + "created": 1620237486, + "modified": 1620237486 + }, + { + "id": 2081804490, + "timezone": "Europe/Paris", + "model": 32, + "model_id": 63, + "hash_deviceid": "201d0b9a0556d6b755166b2cf8d22d3bdf0487ee", + "startdate": 1618345805, + "enddate": 1618373504, + "date": "2021-04-14", + "data": { + "wakeupduration": 3600, + "wakeupcount": 2, + "durationtosleep": 780, + "remsleepduration": 3960, + "durationtowakeup": 300, + "total_sleep_time": 22680, + "sleep_efficiency": 0.86, + "sleep_latency": 780, + "wakeup_latency": 300, + "waso": 3939, + "nb_rem_episodes": 4, + "out_of_bed_count": 3, + "lightsleepduration": 12960, + "deepsleepduration": 5760, + "hr_average": 98, + "hr_min": 70, + "hr_max": 120, + "rr_average": 13, + "rr_min": 10, + "rr_max": 20, + "breathing_disturbances_intensity": 29, + "snoring": 960, + "snoringepisodecount": 16, + "sleep_score": 62, + "apnea_hypopnea_index": 29 + }, + "created": 1620237490, + "modified": 1620237489 + } +] diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index baec7e92ea0..3f20791ac4d 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -120,11 +120,9 @@ async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, webhook_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" await setup_integration(hass, webhook_config_entry) - await hass_client_no_auth() await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() @@ -158,12 +156,10 @@ async def test_webhook_subscription_polling_config( hass: HomeAssistant, withings: AsyncMock, polling_config_entry: MockConfigEntry, - hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, ) -> None: """Test webhook subscriptions not run when polling.""" await setup_integration(hass, polling_config_entry, False) - await hass_client_no_auth() await hass.async_block_till_done() freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6738d9a3eb4..6cf33c45c9d 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,7 +2,6 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch -from aiowithings import Goals, MeasurementGroup from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -11,14 +10,9 @@ from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import setup_integration +from . import load_goals_fixture, load_measurements_fixture, setup_integration -from tests.common import ( - MockConfigEntry, - async_fire_time_changed, - load_json_array_fixture, - load_json_object_fixture, -) +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -78,11 +72,6 @@ async def test_update_updates_incrementally( async_fire_time_changed(hass) await hass.async_block_till_done() - meas_json = load_json_array_fixture("withings/get_meas_1.json") - measurement_groups = [ - MeasurementGroup.from_api(measurement) for measurement in meas_json - ] - assert withings.get_measurement_since.call_args_list == [] await _skip_10_minutes() assert ( @@ -90,7 +79,10 @@ async def test_update_updates_incrementally( == "2019-08-01 12:00:00+00:00" ) - withings.get_measurement_since.return_value = measurement_groups + withings.get_measurement_since.return_value = load_measurements_fixture( + "withings/measurements_1.json" + ) + await _skip_10_minutes() assert ( str(withings.get_measurement_since.call_args_list[1].args[0]) @@ -116,21 +108,16 @@ async def test_update_new_measurement_creates_new_sensor( freezer: FrozenDateTimeFactory, ) -> None: """Test fetching a new measurement will add a new sensor.""" - meas_json = load_json_array_fixture("withings/get_meas_1.json") - measurement_groups = [ - MeasurementGroup.from_api(measurement) for measurement in meas_json - ] - withings.get_measurement_in_period.return_value = measurement_groups + withings.get_measurement_in_period.return_value = load_measurements_fixture( + "withings/measurements_1.json" + ) await setup_integration(hass, polling_config_entry, False) assert hass.states.get("sensor.henk_fat_mass") is None - meas_json = load_json_object_fixture("withings/get_meas.json") - measurement_groups = [ - MeasurementGroup.from_api(measurement) - for measurement in meas_json["measuregrps"] - ] - withings.get_measurement_in_period.return_value = measurement_groups + withings.get_measurement_in_period.return_value = load_measurements_fixture( + "withings/measurements.json" + ) freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) @@ -146,17 +133,15 @@ async def test_update_new_goals_creates_new_sensor( freezer: FrozenDateTimeFactory, ) -> None: """Test fetching new goals will add a new sensor.""" - goals_json = load_json_object_fixture("withings/goals_1.json") - goals = Goals.from_api(goals_json) - withings.get_goals.return_value = goals + + withings.get_goals.return_value = load_goals_fixture("withings/goals_1.json") + await setup_integration(hass, polling_config_entry, False) assert hass.states.get("sensor.henk_step_goal") is None assert hass.states.get("sensor.henk_weight_goal") is not None - goals_json = load_json_object_fixture("withings/goals.json") - goals = Goals.from_api(goals_json) - withings.get_goals.return_value = goals + withings.get_goals.return_value = load_goals_fixture("withings/goals.json") freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) From 2dfb3ba693858a9aab4bfaf6e4397d61e992c2a7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 22:00:14 +0200 Subject: [PATCH 701/968] Bump python-opensky to 0.2.1 (#102467) --- homeassistant/components/opensky/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opensky/__init__.py | 7 +++---- tests/components/opensky/conftest.py | 9 ++++----- tests/components/opensky/test_sensor.py | 12 +++--------- 6 files changed, 13 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 4d1047222ff..d33dfec6adf 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", - "requirements": ["python-opensky==0.2.0"] + "requirements": ["python-opensky==0.2.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index c784e9dad7c..5ee9d20b508 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2163,7 +2163,7 @@ python-mystrom==2.2.0 python-opendata-transport==0.3.0 # homeassistant.components.opensky -python-opensky==0.2.0 +python-opensky==0.2.1 # homeassistant.components.otbr # homeassistant.components.thread diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c9cc360ecd..3aa18738c4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1610,7 +1610,7 @@ python-myq==3.1.13 python-mystrom==2.2.0 # homeassistant.components.opensky -python-opensky==0.2.0 +python-opensky==0.2.1 # homeassistant.components.otbr # homeassistant.components.thread diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py index e746521c72c..0f24f8931af 100644 --- a/tests/components/opensky/__init__.py +++ b/tests/components/opensky/__init__.py @@ -1,10 +1,9 @@ """Opensky tests.""" -import json from unittest.mock import patch from python_opensky import StatesResponse -from tests.common import load_fixture +from tests.common import load_json_object_fixture def patch_setup_entry() -> bool: @@ -16,5 +15,5 @@ def patch_setup_entry() -> bool: def get_states_response_fixture(fixture: str) -> StatesResponse: """Return the states response from json.""" - json_fixture = load_fixture(fixture) - return StatesResponse.parse_obj(json.loads(json_fixture)) + states_json = load_json_object_fixture(fixture) + return StatesResponse.from_api(states_json) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py index f74c18773f5..90e0b7251bf 100644 --- a/tests/components/opensky/conftest.py +++ b/tests/components/opensky/conftest.py @@ -1,10 +1,8 @@ """Configure tests for the OpenSky integration.""" from collections.abc import Awaitable, Callable -import json from unittest.mock import patch import pytest -from python_opensky import StatesResponse from homeassistant.components.opensky.const import ( CONF_ALTITUDE, @@ -21,7 +19,9 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_fixture +from . import get_states_response_fixture + +from tests.common import MockConfigEntry ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] @@ -88,10 +88,9 @@ async def mock_setup_integration( async def func(mock_config_entry: MockConfigEntry) -> None: mock_config_entry.add_to_hass(hass) - json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + return_value=get_states_response_fixture("opensky/states.json"), ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py index b637a0d0356..3429d5eec7e 100644 --- a/tests/components/opensky/test_sensor.py +++ b/tests/components/opensky/test_sensor.py @@ -1,10 +1,8 @@ """OpenSky sensor tests.""" from datetime import timedelta -import json from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory -from python_opensky import StatesResponse from syrupy import SnapshotAssertion from homeassistant.components.opensky.const import ( @@ -18,19 +16,19 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component +from . import get_states_response_fixture from .conftest import ComponentSetup -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, async_fire_time_changed LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} async def test_legacy_migration(hass: HomeAssistant) -> None: """Test migration from yaml to config flow.""" - json_fixture = load_fixture("opensky/states.json") with patch( "python_opensky.OpenSky.get_states", - return_value=StatesResponse.parse_obj(json.loads(json_fixture)), + return_value=get_states_response_fixture("opensky/states.json"), ): assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) await hass.async_block_till_done() @@ -85,10 +83,6 @@ async def test_sensor_updating( """Test updating sensor.""" await setup_integration(config_entry) - def get_states_response_fixture(fixture: str) -> StatesResponse: - json_fixture = load_fixture(fixture) - return StatesResponse.parse_obj(json.loads(json_fixture)) - events = [] async def event_listener(event: Event) -> None: From 27f6c6fdf4abd9236130b9dca45cf9e842f97c8e Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 22 Oct 2023 22:11:43 +0200 Subject: [PATCH 702/968] Add model info in fibaro integration (#102551) --- homeassistant/components/fibaro/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 55b41372faa..cdfa7f6a864 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -101,6 +101,7 @@ class FibaroController: self._event_callbacks: dict[int, list[Callable[[FibaroEvent], None]]] = {} self.hub_serial: str # Unique serial number of the hub self.hub_name: str # The friendly name of the hub + self.hub_model: str self.hub_software_version: str self.hub_api_url: str = config[CONF_URL] # Device infos by fibaro device id @@ -113,6 +114,7 @@ class FibaroController: info = self._client.read_info() self.hub_serial = info.serial_number self.hub_name = info.hc_name + self.hub_model = info.platform self.hub_software_version = info.current_version if connected is False: @@ -409,7 +411,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: serial_number=controller.hub_serial, manufacturer="Fibaro", name=controller.hub_name, - model=controller.hub_serial, + model=controller.hub_model, sw_version=controller.hub_software_version, configuration_url=controller.hub_api_url.removesuffix("/api/"), ) From 0adb6fb02c3729178667dac8f2857d78765a2936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 10:34:34 -1000 Subject: [PATCH 703/968] Bump anyio to 4.0.0 (#102552) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cc6be3705e1..ac3245c2ff1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -103,7 +103,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.1 +anyio==4.0.0 h11==0.14.0 httpcore==0.18.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 78879424098..2668affee96 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -104,7 +104,7 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.7.1 +anyio==4.0.0 h11==0.14.0 httpcore==0.18.0 From e936ca0cb10c47116402a019f9cacb3a15f1bd3c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 22:39:00 +0200 Subject: [PATCH 704/968] Build Pydantic wheels with old Cython (#101976) --- .github/workflows/wheels.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index a51502cd888..3b23f1b5b05 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -176,9 +176,11 @@ jobs: # and don't yet use isolated build environments. # Build these first. # grpcio: https://github.com/grpc/grpc/issues/33918 + # pydantic: https://github.com/pydantic/pydantic/issues/7689 touch requirements_old-cython.txt cat homeassistant/package_constraints.txt | grep 'grpcio==' >> requirements_old-cython.txt + cat homeassistant/package_constraints.txt | grep 'pydantic==' >> requirements_old-cython.txt - name: Adjust build env run: | From 7d2fa5bf60de0c88df3c3476eb4479e5dc273b8f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 22 Oct 2023 22:39:40 +0200 Subject: [PATCH 705/968] Correct range for nibe_heatpump numbers (#102553) --- .coveragerc | 1 - .../components/nibe_heatpump/number.py | 2 + tests/components/nibe_heatpump/__init__.py | 17 +++ tests/components/nibe_heatpump/conftest.py | 13 +- .../nibe_heatpump/snapshots/test_number.ambr | 135 ++++++++++++++++++ tests/components/nibe_heatpump/test_button.py | 14 +- tests/components/nibe_heatpump/test_number.py | 109 ++++++++++++++ 7 files changed, 277 insertions(+), 14 deletions(-) create mode 100644 tests/components/nibe_heatpump/snapshots/test_number.ambr create mode 100644 tests/components/nibe_heatpump/test_number.py diff --git a/.coveragerc b/.coveragerc index b994e61a122..9634ca2edb8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -827,7 +827,6 @@ omit = homeassistant/components/nibe_heatpump/__init__.py homeassistant/components/nibe_heatpump/climate.py homeassistant/components/nibe_heatpump/binary_sensor.py - homeassistant/components/nibe_heatpump/number.py homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py homeassistant/components/nibe_heatpump/switch.py diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 1b3bc928985..8231cc65450 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -50,6 +50,8 @@ class Number(CoilEntity, NumberEntity): self._attr_native_min_value, self._attr_native_max_value, ) = _get_numeric_limits(coil.size) + self._attr_native_min_value /= coil.factor + self._attr_native_max_value /= coil.factor else: self._attr_native_min_value = float(coil.min) self._attr_native_max_value = float(coil.max) diff --git a/tests/components/nibe_heatpump/__init__.py b/tests/components/nibe_heatpump/__init__.py index 5446e289656..d2852ec42f5 100644 --- a/tests/components/nibe_heatpump/__init__.py +++ b/tests/components/nibe_heatpump/__init__.py @@ -2,12 +2,24 @@ from typing import Any +from nibe.heatpump import Model + from homeassistant.components.nibe_heatpump import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +MOCK_ENTRY_DATA = { + "model": None, + "ip_address": "127.0.0.1", + "listening_port": 9999, + "remote_read_port": 10000, + "remote_write_port": 10001, + "word_swap": True, + "connection_type": "nibegw", +} + async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: """Add entry and get the coordinator.""" @@ -17,3 +29,8 @@ async def async_add_entry(hass: HomeAssistant, data: dict[str, Any]) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED + + +async def async_add_model(hass: HomeAssistant, model: Model): + """Add entry of specific model.""" + await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) diff --git a/tests/components/nibe_heatpump/conftest.py b/tests/components/nibe_heatpump/conftest.py index 2a4e2f80ff5..d7343eac69c 100644 --- a/tests/components/nibe_heatpump/conftest.py +++ b/tests/components/nibe_heatpump/conftest.py @@ -62,4 +62,15 @@ async def fixture_coils(mock_connection): mock_connection.read_coil = read_coil mock_connection.read_coils = read_coils - return coils + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.nibe_heatpump import HeatPump + + get_coils_original = HeatPump.get_coils + + def get_coils(x): + coils_data = get_coils_original(x) + return [coil for coil in coils_data if coil.address in coils] + + with patch.object(HeatPump, "get_coils", new=get_coils): + yield coils diff --git a/tests/components/nibe_heatpump/snapshots/test_number.ambr b/tests/components/nibe_heatpump/snapshots/test_number.ambr new file mode 100644 index 00000000000..d174c0cc059 --- /dev/null +++ b/tests/components/nibe_heatpump/snapshots/test_number.ambr @@ -0,0 +1,135 @@ +# serializer version: 1 +# name: test_update[Model.F1155-47011-number.heat_offset_s1_47011--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Heat Offset S1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heat_offset_s1_47011', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.F1155-47011-number.heat_offset_s1_47011-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F1155 Heat Offset S1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heat_offset_s1_47011', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.F1155-47062-number.heat_offset_s1_47011-None] + None +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.F750-47062-number.hw_charge_offset_47062-None] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'F750 HW charge offset', + 'max': 12.7, + 'min': -12.8, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '°C', + }), + 'context': , + 'entity_id': 'number.hw_charge_offset_47062', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031--10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '-10.0', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031-10] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_update[Model.S320-40031-number.heating_offset_climate_system_1_40031-None] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'S320 Heating offset climate system 1', + 'max': 10.0, + 'min': -10.0, + 'mode': , + 'step': 1.0, + }), + 'context': , + 'entity_id': 'number.heating_offset_climate_system_1_40031', + 'last_changed': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/nibe_heatpump/test_button.py b/tests/components/nibe_heatpump/test_button.py index e4f90a59f67..755827fa128 100644 --- a/tests/components/nibe_heatpump/test_button.py +++ b/tests/components/nibe_heatpump/test_button.py @@ -17,20 +17,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from . import async_add_entry +from . import async_add_model from tests.common import async_fire_time_changed -MOCK_ENTRY_DATA = { - "model": None, - "ip_address": "127.0.0.1", - "listening_port": 9999, - "remote_read_port": 10000, - "remote_write_port": 10001, - "word_swap": True, - "connection_type": "nibegw", -} - @pytest.fixture(autouse=True) async def fixture_single_platform(): @@ -62,7 +52,7 @@ async def test_reset_button( coils[unit.alarm_reset] = 0 coils[unit.alarm] = 0 - await async_add_entry(hass, {**MOCK_ENTRY_DATA, "model": model.name}) + await async_add_model(hass, model) state = hass.states.get(entity_id) assert state diff --git a/tests/components/nibe_heatpump/test_number.py b/tests/components/nibe_heatpump/test_number.py new file mode 100644 index 00000000000..5c4d7f4341b --- /dev/null +++ b/tests/components/nibe_heatpump/test_number.py @@ -0,0 +1,109 @@ +"""Test the Nibe Heat Pump config flow.""" +from typing import Any +from unittest.mock import AsyncMock, patch + +from nibe.coil import CoilData +from nibe.heatpump import Model +import pytest +from syrupy import SnapshotAssertion + +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as PLATFORM_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant + +from . import async_add_model + + +@pytest.fixture(autouse=True) +async def fixture_single_platform(): + """Only allow this platform to load.""" + with patch("homeassistant.components.nibe_heatpump.PLATFORMS", [Platform.NUMBER]): + yield + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "value"), + [ + # Tests for S series coils with min/max + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", 10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", -10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", None), + # Tests for F series coils with min/max + (Model.F1155, 47011, "number.heat_offset_s1_47011", 10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", -10), + (Model.F1155, 47062, "number.heat_offset_s1_47011", None), + # Tests for F series coils without min/max + (Model.F750, 47062, "number.hw_charge_offset_47062", 10), + (Model.F750, 47062, "number.hw_charge_offset_47062", -10), + (Model.F750, 47062, "number.hw_charge_offset_47062", None), + ], +) +async def test_update( + hass: HomeAssistant, + model: Model, + entity_id: str, + address: int, + value: Any, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, + snapshot: SnapshotAssertion, +) -> None: + """Test setting of value.""" + coils[address] = value + + await async_add_model(hass, model) + + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state == snapshot + + +@pytest.mark.parametrize( + ("model", "address", "entity_id", "value"), + [ + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", 10), + (Model.S320, 40031, "number.heating_offset_climate_system_1_40031", -10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", 10), + (Model.F1155, 47011, "number.heat_offset_s1_47011", -10), + (Model.F750, 47062, "number.hw_charge_offset_47062", 10), + ], +) +async def test_set_value( + hass: HomeAssistant, + mock_connection: AsyncMock, + model: Model, + entity_id: str, + address: int, + value: Any, + coils: dict[int, Any], + entity_registry_enabled_by_default: None, +) -> None: + """Test setting of value.""" + coils[address] = 0 + + await async_add_model(hass, model) + + await hass.async_block_till_done() + assert hass.states.get(entity_id) + + # Write value + await hass.services.async_call( + PLATFORM_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, + ) + + await hass.async_block_till_done() + + # Verify written + args = mock_connection.write_coil.call_args + assert args + coil = args.args[0] + assert isinstance(coil, CoilData) + assert coil.coil.address == address + assert coil.value == value From 5e30c2ab9ca53c474ca4ddd28792fad0ddcb87d1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 22:42:18 +0200 Subject: [PATCH 706/968] Use dataclass for Withings domain data (#102547) Co-authored-by: J. Nick Koston --- homeassistant/components/withings/__init__.py | 61 +++++++++++-------- .../components/withings/binary_sensor.py | 6 +- homeassistant/components/withings/const.py | 16 +---- .../components/withings/diagnostics.py | 21 ++----- homeassistant/components/withings/sensor.py | 18 ++---- 5 files changed, 51 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index ef91f3368a9..ecf951db4ac 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -7,6 +7,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable import contextlib +from dataclasses import dataclass, field from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -50,17 +51,7 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -from .const import ( - BED_PRESENCE_COORDINATOR, - CONF_PROFILES, - CONF_USE_WEBHOOK, - DEFAULT_TITLE, - DOMAIN, - GOALS_COORDINATOR, - LOGGER, - MEASUREMENT_COORDINATOR, - SLEEP_COORDINATOR, -) +from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import ( WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, @@ -132,6 +123,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +@dataclass(slots=True) +class WithingsData: + """Dataclass to hold withings domain data.""" + + measurement_coordinator: WithingsMeasurementDataUpdateCoordinator + sleep_coordinator: WithingsSleepDataUpdateCoordinator + bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator + goals_coordinator: WithingsGoalsDataUpdateCoordinator + coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) + + def __post_init__(self) -> None: + """Collect all coordinators in a set.""" + self.coordinators = { + self.measurement_coordinator, + self.sleep_coordinator, + self.bed_presence_coordinator, + self.goals_coordinator, + } + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: @@ -156,19 +167,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return token client.refresh_token_function = _refresh_token - coordinators: dict[str, WithingsDataUpdateCoordinator] = { - MEASUREMENT_COORDINATOR: WithingsMeasurementDataUpdateCoordinator(hass, client), - SLEEP_COORDINATOR: WithingsSleepDataUpdateCoordinator(hass, client), - BED_PRESENCE_COORDINATOR: WithingsBedPresenceDataUpdateCoordinator( - hass, client - ), - GOALS_COORDINATOR: WithingsGoalsDataUpdateCoordinator(hass, client), - } + withings_data = WithingsData( + measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), + sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), + bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), + goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), + ) - for coordinator in coordinators.values(): + for coordinator in withings_data.coordinators: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data async def unregister_webhook( _: Any, @@ -176,7 +185,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Unregister Withings webhook (%s)", entry.data[CONF_WEBHOOK_ID]) webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) await async_unsubscribe_webhooks(client) - for coordinator in coordinators.values(): + for coordinator in withings_data.coordinators: coordinator.webhook_subscription_listener(False) async def register_webhook( @@ -203,12 +212,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: DOMAIN, webhook_name, entry.data[CONF_WEBHOOK_ID], - get_webhook_handler(coordinators), + get_webhook_handler(withings_data), allowed_methods=[METH_POST], ) await async_subscribe_webhooks(client, webhook_url) - for coordinator in coordinators.values(): + for coordinator in withings_data.coordinators: coordinator.webhook_subscription_listener(True) LOGGER.debug("Register Withings webhook: %s", webhook_url) entry.async_on_unload( @@ -325,7 +334,7 @@ def json_message_response(message: str, message_code: int) -> Response: def get_webhook_handler( - coordinators: dict[str, WithingsDataUpdateCoordinator], + withings_data: WithingsData, ) -> Callable[[HomeAssistant, str, Request], Awaitable[Response | None]]: """Return webhook handler.""" @@ -349,7 +358,7 @@ def get_webhook_handler( NotificationCategory.UNKNOWN, ) - for coordinator in coordinators.values(): + for coordinator in withings_data.coordinators: if notification_category in coordinator.notification_categories: await coordinator.async_webhook_data_updated(notification_category) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 24698f90809..69af68e988b 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BED_PRESENCE_COORDINATOR, DOMAIN +from .const import DOMAIN from .coordinator import WithingsBedPresenceDataUpdateCoordinator from .entity import WithingsEntity @@ -20,9 +20,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator: WithingsBedPresenceDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][BED_PRESENCE_COORDINATOR] + coordinator = hass.data[DOMAIN][entry.entry_id].bed_presence_coordinator entities = [WithingsBinarySensor(coordinator)] diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index f04500bb3b8..a4a34375459 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,25 +1,13 @@ """Constants used by the Withings component.""" import logging +LOGGER = logging.getLogger(__package__) + DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" -DATA_MANAGER = "data_manager" - -CONFIG = "config" DOMAIN = "withings" -LOG_NAMESPACE = "homeassistant.components.withings" -PROFILE = "profile" -PUSH_HANDLER = "push_handler" - -MEASUREMENT_COORDINATOR = "measurement_coordinator" -SLEEP_COORDINATOR = "sleep_coordinator" -BED_PRESENCE_COORDINATOR = "bed_presence_coordinator" -GOALS_COORDINATOR = "goals_coordinator" - -LOGGER = logging.getLogger(__package__) - SCORE_POINTS = "points" UOM_BEATS_PER_MINUTE = "bpm" diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index efa0421f205..7ed9f6ce2c9 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -10,12 +10,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from . import ( - CONF_CLOUDHOOK_URL, - WithingsMeasurementDataUpdateCoordinator, - WithingsSleepDataUpdateCoordinator, -) -from .const import DOMAIN, MEASUREMENT_COORDINATOR, SLEEP_COORDINATOR +from . import CONF_CLOUDHOOK_URL, WithingsData +from .const import DOMAIN async def async_get_config_entry_diagnostics( @@ -29,17 +25,12 @@ async def async_get_config_entry_diagnostics( has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data - measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[ - DOMAIN - ][entry.entry_id][MEASUREMENT_COORDINATOR] - sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][SLEEP_COORDINATOR] + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] return { "has_valid_external_webhook_url": has_valid_external_webhook_url, "has_cloudhooks": has_cloudhooks, - "webhooks_connected": measurement_coordinator.webhooks_connected, - "received_measurements": list(measurement_coordinator.data), - "received_sleep_data": sleep_coordinator.data is not None, + "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, + "received_measurements": list(withings_data.measurement_coordinator.data), + "received_sleep_data": withings_data.sleep_coordinator.data is not None, } diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 54c13500e1d..1530054ad69 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -25,12 +25,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import WithingsData from .const import ( DOMAIN, - GOALS_COORDINATOR, - MEASUREMENT_COORDINATOR, SCORE_POINTS, - SLEEP_COORDINATOR, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, UOM_FREQUENCY, @@ -462,9 +460,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - measurement_coordinator: WithingsMeasurementDataUpdateCoordinator = hass.data[ - DOMAIN - ][entry.entry_id][MEASUREMENT_COORDINATOR] + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + measurement_coordinator = withings_data.measurement_coordinator entities: list[SensorEntity] = [] entities.extend( @@ -492,9 +490,7 @@ async def async_setup_entry( measurement_coordinator.async_add_listener(_async_measurement_listener) - goals_coordinator: WithingsGoalsDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][GOALS_COORDINATOR] + goals_coordinator = withings_data.goals_coordinator current_goals = get_current_goals(goals_coordinator.data) @@ -516,9 +512,7 @@ async def async_setup_entry( goals_coordinator.async_add_listener(_async_goals_listener) - sleep_coordinator: WithingsSleepDataUpdateCoordinator = hass.data[DOMAIN][ - entry.entry_id - ][SLEEP_COORDINATOR] + sleep_coordinator = withings_data.sleep_coordinator entities.extend( WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS From c2abc3dceccb945d924059b496bc2e239a43a8b5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 22 Oct 2023 23:34:04 +0200 Subject: [PATCH 707/968] Fix brightness and color_temp can be None for alexa light entities (#102554) * Fix brightness and color_temp can be None in alexa * Add test --- .../components/alexa/capabilities.py | 10 ++--- tests/components/alexa/test_smart_home.py | 40 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index a7065a38686..5d749fdf430 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -580,8 +580,8 @@ class AlexaBrightnessController(AlexaCapability): """Read and return a property.""" if name != "brightness": raise UnsupportedProperty(name) - if "brightness" in self.entity.attributes: - return round(self.entity.attributes["brightness"] / 255.0 * 100) + if brightness := self.entity.attributes.get("brightness"): + return round(brightness / 255.0 * 100) return 0 @@ -683,10 +683,8 @@ class AlexaColorTemperatureController(AlexaCapability): """Read and return a property.""" if name != "colorTemperatureInKelvin": raise UnsupportedProperty(name) - if "color_temp" in self.entity.attributes: - return color_util.color_temperature_mired_to_kelvin( - self.entity.attributes["color_temp"] - ) + if color_temp := self.entity.attributes.get("color_temp"): + return color_util.color_temperature_mired_to_kelvin(color_temp) return None diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index bbdf3efeb5f..99dd79fe2e2 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -272,6 +272,46 @@ async def test_dimmable_light(hass: HomeAssistant) -> None: assert call.data["brightness_pct"] == 50 +async def test_dimmable_light_with_none_brightness(hass: HomeAssistant) -> None: + """Test dimmable light discovery.""" + device = ( + "light.test_2", + "on", + { + "brightness": None, + "friendly_name": "Test light 2", + "supported_color_modes": ["brightness"], + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "light#test_2" + assert appliance["displayCategories"][0] == "LIGHT" + assert appliance["friendlyName"] == "Test light 2" + + assert_endpoint_capabilities( + appliance, + "Alexa.BrightnessController", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "light#test_2") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + properties.assert_equal("Alexa.BrightnessController", "brightness", 0) + + call, _ = await assert_request_calls_service( + "Alexa.BrightnessController", + "SetBrightness", + "light#test_2", + "light.turn_on", + hass, + payload={"brightness": "50"}, + ) + assert call.data["brightness_pct"] == 50 + + @pytest.mark.parametrize( "supported_color_modes", [["color_temp", "hs"], ["color_temp", "rgb"], ["color_temp", "xy"]], From 37fdb4950a3ff90ad988fe10f378879ae7f98140 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sun, 22 Oct 2023 23:36:41 +0200 Subject: [PATCH 708/968] Refactor fibaro scene test (#102452) --- tests/components/fibaro/conftest.py | 87 +++++++++++++++++++-------- tests/components/fibaro/test_scene.py | 47 +++++++++++---- 2 files changed, 99 insertions(+), 35 deletions(-) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 2b6580c3191..e15d6509a00 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -2,16 +2,21 @@ from collections.abc import Generator from unittest.mock import AsyncMock, Mock, patch -from pyfibaro.fibaro_scene import SceneModel import pytest -from homeassistant.components.fibaro import DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import Platform +from homeassistant.components.fibaro import CONF_IMPORT_PLUGINS, DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +TEST_SERIALNUMBER = "HC2-111111" +TEST_NAME = "my_fibaro_home_center" +TEST_URL = "http://192.168.1.1/api/" +TEST_USERNAME = "user" +TEST_PASSWORD = "password" +TEST_VERSION = "4.360" + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -22,10 +27,10 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup_entry -@pytest.fixture(name="fibaro_scene") -def mock_scene() -> SceneModel: +@pytest.fixture +def mock_scene() -> Mock: """Fixture for an individual scene.""" - scene = Mock(SceneModel) + scene = Mock() scene.fibaro_id = 1 scene.name = "Test scene" scene.room_id = 1 @@ -33,23 +38,57 @@ def mock_scene() -> SceneModel: return scene -async def setup_platform( - hass: HomeAssistant, - platform: Platform, - room_name: str | None, - scenes: list[SceneModel], -) -> ConfigEntry: - """Set up the fibaro platform and prerequisites.""" - hass.config.components.add(DOMAIN) - config_entry = MockConfigEntry(domain=DOMAIN, title="Test") - config_entry.add_to_hass(hass) +@pytest.fixture +def mock_room() -> Mock: + """Fixture for an individual room.""" + room = Mock() + room.fibaro_id = 1 + room.name = "Room 1" + return room - controller_mock = Mock() - controller_mock.hub_serial = "HC2-111111" - controller_mock.get_room_name.return_value = room_name - controller_mock.read_scenes.return_value = scenes - hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} - await hass.config_entries.async_forward_entry_setup(config_entry, platform) +@pytest.fixture +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Return the default mocked config entry.""" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + CONF_IMPORT_PLUGINS: True, + }, + ) + mock_config_entry.add_to_hass(hass) + return mock_config_entry + + +@pytest.fixture +def mock_fibaro_client() -> Generator[Mock, None, None]: + """Return a mocked FibaroClient.""" + info_mock = Mock() + info_mock.serial_number = TEST_SERIALNUMBER + info_mock.hc_name = TEST_NAME + info_mock.current_version = TEST_VERSION + + with patch( + "homeassistant.components.fibaro.FibaroClient", autospec=True + ) as fibaro_client_mock: + client = fibaro_client_mock.return_value + client.set_authentication.return_value = None + client.connect.return_value = True + client.read_info.return_value = info_mock + client.read_rooms.return_value = [] + client.read_scenes.return_value = [] + client.read_devices.return_value = [] + client.register_update_handler.return_value = None + client.unregister_update_handler.return_value = None + yield client + + +async def init_integration( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Set up the fibaro integration for testing.""" + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - return config_entry diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py index 09e0543976f..0ce618e903c 100644 --- a/tests/components/fibaro/test_scene.py +++ b/tests/components/fibaro/test_scene.py @@ -1,21 +1,30 @@ """Test the Fibaro scene platform.""" - -from pyfibaro.fibaro_scene import SceneModel +from unittest.mock import Mock from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import setup_platform +from .conftest import init_integration + +from tests.common import MockConfigEntry -async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: +async def test_entity_attributes( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, +) -> None: """Test that the attributes of the entity are correct.""" # Arrange + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] entity_registry = er.async_get(hass) # Act - await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + await init_integration(hass, mock_config_entry) # Assert entry = entity_registry.async_get("scene.room_1_test_scene") @@ -25,13 +34,20 @@ async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) async def test_entity_attributes_without_room( - hass: HomeAssistant, fibaro_scene: SceneModel + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, ) -> None: """Test that the attributes of the entity are correct.""" # Arrange + mock_room.name = None + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] entity_registry = er.async_get(hass) # Act - await setup_platform(hass, Platform.SCENE, None, [fibaro_scene]) + await init_integration(hass, mock_config_entry) # Assert entry = entity_registry.async_get("scene.unknown_test_scene") @@ -39,10 +55,19 @@ async def test_entity_attributes_without_room( assert entry.unique_id == "hc2_111111.scene.1" -async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: +async def test_activate_scene( + hass: HomeAssistant, + mock_fibaro_client: Mock, + mock_config_entry: MockConfigEntry, + mock_scene: Mock, + mock_room: Mock, +) -> None: """Test activate scene is called.""" # Arrange - await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + mock_fibaro_client.read_rooms.return_value = [mock_room] + mock_fibaro_client.read_scenes.return_value = [mock_scene] + # Act + await init_integration(hass, mock_config_entry) # Act await hass.services.async_call( SCENE_DOMAIN, @@ -51,4 +76,4 @@ async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> blocking=True, ) # Assert - assert fibaro_scene.start.call_count == 1 + assert mock_scene.start.call_count == 1 From 721c45b7a318de1a05c79c5fb0940b3885ac59fa Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 22 Oct 2023 23:39:54 +0200 Subject: [PATCH 709/968] Rework UniFi client configuration (#99483) --- homeassistant/components/unifi/config_flow.py | 29 ++++++++++++++++++- homeassistant/components/unifi/const.py | 1 + homeassistant/components/unifi/controller.py | 4 +++ .../components/unifi/device_tracker.py | 3 ++ homeassistant/components/unifi/sensor.py | 22 ++++++++++++-- homeassistant/components/unifi/strings.json | 7 +++++ homeassistant/components/unifi/switch.py | 10 ++++++- tests/components/unifi/test_config_flow.py | 13 +++++++++ tests/components/unifi/test_device_tracker.py | 15 ++++++++-- 9 files changed, 96 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 8c0696463c5..a678517eca9 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -34,6 +34,7 @@ from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -257,7 +258,7 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients if self.show_advanced_options: - return await self.async_step_device_tracker() + return await self.async_step_configure_entity_sources() return await self.async_step_simple_options() @@ -296,6 +297,32 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): last_step=True, ) + async def async_step_configure_entity_sources( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select sources for entities.""" + if user_input is not None: + self.options.update(user_input) + return await self.async_step_device_tracker() + + clients = { + client.mac: f"{client.name or client.hostname} ({client.mac})" + for client in self.controller.api.clients.values() + } + + return self.async_show_form( + step_id="configure_entity_sources", + data_schema=vol.Schema( + { + vol.Optional( + CONF_CLIENT_SOURCE, + default=self.options.get(CONF_CLIENT_SOURCE, []), + ): cv.multi_select(clients), + } + ), + last_step=False, + ) + async def async_step_device_tracker( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index 176511645aa..c78313f66e2 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -23,6 +23,7 @@ UNIFI_WIRELESS_CLIENTS = "unifi_wireless_clients" CONF_ALLOW_BANDWIDTH_SENSORS = "allow_bandwidth_sensors" CONF_ALLOW_UPTIME_SENSORS = "allow_uptime_sensors" CONF_BLOCK_CLIENT = "block_client" +CONF_CLIENT_SOURCE = "client_source" CONF_DETECTION_TIME = "detection_time" CONF_DPI_RESTRICTIONS = "dpi_restrictions" CONF_IGNORE_WIRED_BUG = "ignore_wired_bug" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b0ce43fe959..108ff87d026 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -47,6 +47,7 @@ from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -109,6 +110,9 @@ class UniFiController: """Store attributes to avoid property call overhead since they are called frequently.""" options = self.config_entry.options + # Allow creating entities from clients. + self.option_supported_clients: list[str] = options.get(CONF_CLIENT_SOURCE, []) + # Device tracker options # Config entry option to not track clients. diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 22a530e0369..5c9694c669c 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -80,6 +80,9 @@ WIRELESS_DISCONNECTION = ( @callback def async_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + if not controller.option_track_clients: return False diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index f07269dfdd2..3d0ffa1896e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -49,6 +49,22 @@ from .entity import ( ) +@callback +def async_bandwidth_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return controller.option_allow_bandwidth_sensors + + +@callback +def async_uptime_sensor_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return controller.option_allow_uptime_sensors + + @callback def async_client_rx_value_fn(controller: UniFiController, client: Client) -> float: """Calculate receiving data transfer value.""" @@ -139,7 +155,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:upload", has_entity_name=True, - allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, + allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, _: controller.available, device_info_fn=async_client_device_info_fn, @@ -159,7 +175,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfDataRate.MEGABYTES_PER_SECOND, icon="mdi:download", has_entity_name=True, - allowed_fn=lambda controller, _: controller.option_allow_bandwidth_sensors, + allowed_fn=async_bandwidth_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, _: controller.available, device_info_fn=async_client_device_info_fn, @@ -198,7 +214,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, entity_registry_enabled_default=False, - allowed_fn=lambda controller, _: controller.option_allow_uptime_sensors, + allowed_fn=async_uptime_sensor_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, obj_id: controller.available, device_info_fn=async_client_device_info_fn, diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index e441d4695ed..9c609ca8c07 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -30,6 +30,13 @@ "integration_not_setup": "UniFi integration is not set up" }, "step": { + "configure_entity_sources": { + "data": { + "client_source": "Create entities from network clients" + }, + "description": "Select sources to create entities from", + "title": "UniFi Network Entity Sources" + }, "device_tracker": { "data": { "detection_time": "Time in seconds from last seen until considered away", diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 0aa39914686..41c1f55a22a 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -60,6 +60,14 @@ CLIENT_BLOCKED = (EventKey.WIRED_CLIENT_BLOCKED, EventKey.WIRELESS_CLIENT_BLOCKE CLIENT_UNBLOCKED = (EventKey.WIRED_CLIENT_UNBLOCKED, EventKey.WIRELESS_CLIENT_UNBLOCKED) +@callback +def async_block_client_allowed_fn(controller: UniFiController, obj_id: str) -> bool: + """Check if client is allowed.""" + if obj_id in controller.option_supported_clients: + return True + return obj_id in controller.option_block_clients + + @callback def async_dpi_group_is_on_fn( controller: UniFiController, dpi_group: DPIRestrictionGroup @@ -198,7 +206,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, has_entity_name=True, icon="mdi:ethernet", - allowed_fn=lambda controller, obj_id: obj_id in controller.option_block_clients, + allowed_fn=async_block_client_allowed_fn, api_handler_fn=lambda api: api.clients, available_fn=lambda controller, obj_id: controller.available, control_fn=async_block_client_control_fn, diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index ba906f86eef..71d2dc038c1 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_DETECTION_TIME, CONF_DPI_RESTRICTIONS, CONF_IGNORE_WIRED_BUG, @@ -462,6 +463,17 @@ async def test_advanced_option_flow( config_entry.entry_id, context={"show_advanced_options": True} ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "configure_entity_sources" + assert not result["last_step"] + assert list(result["data_schema"].schema[CONF_CLIENT_SOURCE].options.keys()) == [ + "00:00:00:00:00:01" + ] + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CLIENT_SOURCE: ["00:00:00:00:00:01"]}, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "device_tracker" assert not result["last_step"] @@ -510,6 +522,7 @@ async def test_advanced_option_flow( assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"] == { + CONF_CLIENT_SOURCE: ["00:00:00:00:00:01"], CONF_TRACK_CLIENTS: False, CONF_TRACK_WIRED_CLIENTS: False, CONF_TRACK_DEVICES: False, diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 2680a357d77..cbff868d9a6 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -9,6 +9,7 @@ from homeassistant import config_entries from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.unifi.const import ( CONF_BLOCK_CLIENT, + CONF_CLIENT_SOURCE, CONF_IGNORE_WIRED_BUG, CONF_SSID_FILTER, CONF_TRACK_CLIENTS, @@ -132,21 +133,29 @@ async def test_tracked_clients( "last_seen": None, "mac": "00:00:00:00:00:05", } + client_6 = { + "hostname": "client_6", + "ip": "10.0.0.6", + "is_wired": True, + "last_seen": 1562600145, + "mac": "00:00:00:00:00:06", + } await setup_unifi_integration( hass, aioclient_mock, - options={CONF_SSID_FILTER: ["ssid"]}, - clients_response=[client_1, client_2, client_3, client_4, client_5], + options={CONF_SSID_FILTER: ["ssid"], CONF_CLIENT_SOURCE: [client_6["mac"]]}, + clients_response=[client_1, client_2, client_3, client_4, client_5, client_6], known_wireless_clients=(client_4["mac"],), ) - assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 4 + assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 5 assert hass.states.get("device_tracker.client_1").state == STATE_NOT_HOME assert hass.states.get("device_tracker.client_2").state == STATE_NOT_HOME assert ( hass.states.get("device_tracker.client_5").attributes["host_name"] == "client_5" ) + assert hass.states.get("device_tracker.client_6").state == STATE_NOT_HOME # Client on SSID not in SSID filter assert not hass.states.get("device_tracker.client_3") From c8007b841b5e38f3ccdb8fa1dc7f552b2c4cdfd6 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Sun, 22 Oct 2023 17:40:44 -0400 Subject: [PATCH 710/968] Add exclude DB option to backup service call (#101958) --- homeassistant/components/hassio/__init__.py | 2 ++ homeassistant/components/hassio/const.py | 1 + homeassistant/components/hassio/services.yaml | 8 ++++++++ homeassistant/components/hassio/strings.json | 8 ++++++++ tests/components/hassio/test_init.py | 2 ++ 5 files changed, 21 insertions(+) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 392671a5471..91b87416c15 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -64,6 +64,7 @@ from .const import ( ATTR_COMPRESSED, ATTR_FOLDERS, ATTR_HOMEASSISTANT, + ATTR_HOMEASSISTANT_EXCLUDE_DATABASE, ATTR_INPUT, ATTR_LOCATION, ATTR_PASSWORD, @@ -193,6 +194,7 @@ SCHEMA_BACKUP_FULL = vol.Schema( vol.Optional(ATTR_LOCATION): vol.All( cv.string, lambda v: None if v == "/backup" else v ), + vol.Optional(ATTR_HOMEASSISTANT_EXCLUDE_DATABASE): cv.boolean, } ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 9b52057a914..193d4762c5a 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -16,6 +16,7 @@ ATTR_ENDPOINT = "endpoint" ATTR_FOLDERS = "folders" ATTR_HEALTHY = "healthy" ATTR_HOMEASSISTANT = "homeassistant" +ATTR_HOMEASSISTANT_EXCLUDE_DATABASE = "homeassistant_exclude_database" ATTR_INPUT = "input" ATTR_ISSUES = "issues" ATTR_METHOD = "method" diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index 33eb1e88ed3..30086e4dd2b 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -58,12 +58,20 @@ backup_full: example: my_backup_mount selector: backup_location: + homeassistant_exclude_database: + default: false + selector: + boolean: backup_partial: fields: homeassistant: selector: boolean: + homeassistant_exclude_database: + default: false + selector: + boolean: addons: example: ["core_ssh", "core_samba", "core_mosquitto"] selector: diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index bdd94933b2b..77ef408cafe 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -296,6 +296,10 @@ "location": { "name": "[%key:common::config_flow::data::location%]", "description": "Name of a backup network storage to host backups." + }, + "homeassistant_exclude_database": { + "name": "Home Assistant exclude database", + "description": "Exclude the Home Assistant database file from backup" } } }, @@ -330,6 +334,10 @@ "location": { "name": "[%key:common::config_flow::data::location%]", "description": "[%key:component::hassio::services::backup_full::fields::location::description%]" + }, + "homeassistant_exclude_database": { + "name": "Home Assistant exclude database", + "description": "Exclude the Home Assistant database file from backup" } } }, diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index adb462b02e3..c04a26638e6 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -585,6 +585,7 @@ async def test_service_calls( { "name": "backup_name", "location": "backup_share", + "homeassistant_exclude_database": True, }, ) await hass.async_block_till_done() @@ -593,6 +594,7 @@ async def test_service_calls( assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", + "homeassistant_exclude_database": True, } await hass.services.async_call( From bc45de627a749dfc6876d30d4be266bc12a64345 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 22 Oct 2023 23:44:38 +0200 Subject: [PATCH 711/968] Allow negative minimum temperature for modbus (#102118) --- homeassistant/components/modbus/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 85fba66b68a..a2b0c24464c 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -232,8 +232,8 @@ CLIMATE_SCHEMA = vol.All( { vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, - vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_int, - vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_int, + vol.Optional(CONF_MAX_TEMP, default=35): number_validator, + vol.Optional(CONF_MIN_TEMP, default=5): number_validator, vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, From 164872e1af24fb1bc92a795ef4b46ff22f84f3eb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sun, 22 Oct 2023 23:45:27 +0200 Subject: [PATCH 712/968] Improve error messages from translation script (#102098) Co-authored-by: Robert Resch --- script/translations/clean.py | 11 +++++------ script/translations/deduplicate.py | 4 ++-- script/translations/download.py | 4 ++-- script/translations/error.py | 26 ++++++++++++++++++++++++++ script/translations/frontend.py | 4 ++-- script/translations/migrate.py | 16 ++++++++-------- script/translations/upload.py | 6 +++--- script/translations/util.py | 12 +++++++++++- 8 files changed, 59 insertions(+), 24 deletions(-) diff --git a/script/translations/clean.py b/script/translations/clean.py index 0dcf40941ef..0f2eb40300d 100644 --- a/script/translations/clean.py +++ b/script/translations/clean.py @@ -1,11 +1,10 @@ """Find translation keys that are in Lokalise but no longer defined in source.""" import argparse -import json from .const import CORE_PROJECT_ID, FRONTEND_DIR, FRONTEND_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp from .lokalise import get_api -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: @@ -46,9 +45,9 @@ def find_core(): translations = int_dir / "translations" / "en.json" - strings_json = json.loads(strings.read_text()) + strings_json = load_json_from_path(strings) if translations.is_file(): - translations_json = json.loads(translations.read_text()) + translations_json = load_json_from_path(translations) else: translations_json = {} @@ -69,8 +68,8 @@ def find_frontend(): missing_keys = [] find_extra( - json.loads(source.read_text()), - json.loads(translated.read_text()), + load_json_from_path(source), + load_json_from_path(translated), "", missing_keys, ) diff --git a/script/translations/deduplicate.py b/script/translations/deduplicate.py index 86812318218..27764f0987f 100644 --- a/script/translations/deduplicate.py +++ b/script/translations/deduplicate.py @@ -9,7 +9,7 @@ from homeassistant.const import Platform from . import upload from .develop import flatten_translations -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path def get_arguments() -> argparse.Namespace: @@ -101,7 +101,7 @@ def run(): for component in components: comp_strings_path = Path(STRINGS_PATH.format(component)) - strings[component] = json.loads(comp_strings_path.read_text(encoding="utf-8")) + strings[component] = load_json_from_path(comp_strings_path) for path, value in update_keys.items(): parts = path.split("::") diff --git a/script/translations/download.py b/script/translations/download.py index bcab3b511c3..d02b5c869aa 100755 --- a/script/translations/download.py +++ b/script/translations/download.py @@ -10,7 +10,7 @@ import subprocess from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_lokalise_token +from .util import get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") DOWNLOAD_DIR = pathlib.Path("build/translations-download").absolute() @@ -122,7 +122,7 @@ def write_integration_translations(): """Write integration translations.""" for lang_file in DOWNLOAD_DIR.glob("*.json"): lang = lang_file.stem - translations = json.loads(lang_file.read_text()) + translations = load_json_from_path(lang_file) save_language_translations(lang, translations) diff --git a/script/translations/error.py b/script/translations/error.py index bc8f21c23b5..210af95f325 100644 --- a/script/translations/error.py +++ b/script/translations/error.py @@ -1,4 +1,5 @@ """Errors for translations.""" +import json class ExitApp(Exception): @@ -8,3 +9,28 @@ class ExitApp(Exception): """Initialize the exit app exception.""" self.reason = reason self.exit_code = exit_code + + +class JSONDecodeErrorWithPath(json.JSONDecodeError): + """Subclass of JSONDecodeError with additional properties. + + Additional properties: + path: Path to the JSON document being parsed + """ + + def __init__(self, msg, doc, pos, path): + """Initialize.""" + lineno = doc.count("\n", 0, pos) + 1 + colno = pos - doc.rfind("\n", 0, pos) + errmsg = f"{msg}: file: {path} line {lineno} column {colno} (char {pos})" + ValueError.__init__(self, errmsg) + self.msg = msg + self.doc = doc + self.pos = pos + self.lineno = lineno + self.colno = colno + self.path = path + + def __reduce__(self): + """Reduce.""" + return self.__class__, (self.msg, self.doc, self.pos, self.path) diff --git a/script/translations/frontend.py b/script/translations/frontend.py index c955c240478..bb0e98e1c93 100644 --- a/script/translations/frontend.py +++ b/script/translations/frontend.py @@ -4,7 +4,7 @@ import json from .const import FRONTEND_DIR from .download import DOWNLOAD_DIR, run_download_docker -from .util import get_base_arg_parser +from .util import get_base_arg_parser, load_json_from_path FRONTEND_BACKEND_TRANSLATIONS = FRONTEND_DIR / "translations/backend" @@ -29,7 +29,7 @@ def run(): run_download_docker() for lang_file in DOWNLOAD_DIR.glob("*.json"): - translations = json.loads(lang_file.read_text()) + translations = load_json_from_path(lang_file) to_write_translations = {"component": {}} diff --git a/script/translations/migrate.py b/script/translations/migrate.py index f5bd60c30b4..c3057800973 100644 --- a/script/translations/migrate.py +++ b/script/translations/migrate.py @@ -6,6 +6,7 @@ import re from .const import CORE_PROJECT_ID, FRONTEND_PROJECT_ID, INTEGRATIONS_DIR from .lokalise import get_api +from .util import load_json_from_path FRONTEND_REPO = pathlib.Path("../frontend/") @@ -164,7 +165,7 @@ def find_and_rename_keys(): if not strings_file.is_file(): continue - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) if "title" in strings.get("config", {}): from_key = f"component::{integration.name}::config::title" @@ -194,12 +195,12 @@ def interactive_update(): if not strings_file.is_file(): continue - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) if "title" not in strings: continue - manifest = json.loads((integration / "manifest.json").read_text()) + manifest = load_json_from_path(integration / "manifest.json") print("Processing", manifest["name"]) print("Translation title", strings["title"]) @@ -247,9 +248,8 @@ def find_frontend_states(): Source key -> target key Add key to integrations strings.json """ - frontend_states = json.loads( - (FRONTEND_REPO / "src/translations/en.json").read_text() - )["state"] + path = FRONTEND_REPO / "src/translations/en.json" + frontend_states = load_json_from_path(path)["state"] # domain => state object to_write = {} @@ -307,7 +307,7 @@ def find_frontend_states(): for domain, state in to_write.items(): strings = INTEGRATIONS_DIR / domain / "strings.json" if strings.is_file(): - content = json.loads(strings.read_text()) + content = load_json_from_path(strings) else: content = {} @@ -326,7 +326,7 @@ def find_frontend_states(): def apply_data_references(to_migrate): """Apply references.""" for strings_file in INTEGRATIONS_DIR.glob("*/strings.json"): - strings = json.loads(strings_file.read_text()) + strings = load_json_from_path(strings_file) steps = strings.get("config", {}).get("step") if not steps: diff --git a/script/translations/upload.py b/script/translations/upload.py index 1a1819af863..eaf1c07ad91 100755 --- a/script/translations/upload.py +++ b/script/translations/upload.py @@ -8,7 +8,7 @@ import subprocess from .const import CLI_2_DOCKER_IMAGE, CORE_PROJECT_ID, INTEGRATIONS_DIR from .error import ExitApp -from .util import get_current_branch, get_lokalise_token +from .util import get_current_branch, get_lokalise_token, load_json_from_path FILENAME_FORMAT = re.compile(r"strings\.(?P\w+)\.json") LOCAL_FILE = pathlib.Path("build/translations-upload.json").absolute() @@ -52,7 +52,7 @@ def run_upload_docker(): def generate_upload_data(): """Generate the data for uploading.""" - translations = json.loads((INTEGRATIONS_DIR.parent / "strings.json").read_text()) + translations = load_json_from_path(INTEGRATIONS_DIR.parent / "strings.json") translations["component"] = {} for path in INTEGRATIONS_DIR.glob(f"*{os.sep}strings*.json"): @@ -66,7 +66,7 @@ def generate_upload_data(): platforms = parent.setdefault("platform", {}) parent = platforms.setdefault(platform, {}) - parent.update(json.loads(path.read_text())) + parent.update(load_json_from_path(path)) return translations diff --git a/script/translations/util.py b/script/translations/util.py index 9f41253fa02..aab98e049d9 100644 --- a/script/translations/util.py +++ b/script/translations/util.py @@ -1,10 +1,12 @@ """Translation utils.""" import argparse +import json import os import pathlib import subprocess +from typing import Any -from .error import ExitApp +from .error import ExitApp, JSONDecodeErrorWithPath def get_base_arg_parser() -> argparse.ArgumentParser: @@ -55,3 +57,11 @@ def get_current_branch(): .stdout.decode() .strip() ) + + +def load_json_from_path(path: pathlib.Path) -> Any: + """Load JSON from path.""" + try: + return json.loads(path.read_text()) + except json.JSONDecodeError as err: + raise JSONDecodeErrorWithPath(err.msg, err.doc, err.pos, path) from err From 04b883a8e93f3e1d64f866d9462676ae04ed892b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 23:47:18 +0200 Subject: [PATCH 713/968] Add activity sensors to Withings (#102501) Co-authored-by: J. Nick Koston --- homeassistant/components/withings/__init__.py | 4 + .../components/withings/coordinator.py | 42 ++- homeassistant/components/withings/sensor.py | 153 +++++++++- .../components/withings/strings.json | 27 ++ tests/components/withings/__init__.py | 10 +- tests/components/withings/conftest.py | 14 +- .../withings/fixtures/activity.json | 282 ++++++++++++++++++ .../withings/snapshots/test_sensor.ambr | 143 +++++++++ tests/components/withings/test_sensor.py | 107 ++++++- 9 files changed, 769 insertions(+), 13 deletions(-) create mode 100644 tests/components/withings/fixtures/activity.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index ecf951db4ac..92cec96ce97 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -53,6 +53,7 @@ from homeassistant.helpers.typing import ConfigType from .const import CONF_PROFILES, CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN, LOGGER from .coordinator import ( + WithingsActivityDataUpdateCoordinator, WithingsBedPresenceDataUpdateCoordinator, WithingsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, @@ -131,6 +132,7 @@ class WithingsData: sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator + activity_coordinator: WithingsActivityDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -140,6 +142,7 @@ class WithingsData: self.sleep_coordinator, self.bed_presence_coordinator, self.goals_coordinator, + self.activity_coordinator, } @@ -172,6 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), + activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 2700b833cea..3b39dddb27e 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,9 +1,10 @@ """Withings coordinator.""" from abc import abstractmethod -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from typing import TypeVar from aiowithings import ( + Activity, Goals, MeasurementType, NotificationCategory, @@ -81,7 +82,6 @@ class WithingsMeasurementDataUpdateCoordinator( super().__init__(hass, client) self.notification_categories = { NotificationCategory.WEIGHT, - NotificationCategory.ACTIVITY, NotificationCategory.PRESSURE, } self._previous_data: dict[MeasurementType, float] = {} @@ -185,3 +185,41 @@ class WithingsGoalsDataUpdateCoordinator(WithingsDataUpdateCoordinator[Goals]): async def _internal_update_data(self) -> Goals: """Retrieve goals data.""" return await self._client.get_goals() + + +class WithingsActivityDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Activity | None] +): + """Withings activity coordinator.""" + + _previous_data: Activity | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Activity | None: + """Retrieve latest activity.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + activities = await self._client.get_activities_in_period( + startdate.date(), now.date() + ) + else: + activities = await self._client.get_activities_since( + self._last_valid_update + ) + + today = date.today() + for activity in activities: + if activity.date == today: + self._previous_data = activity + self._last_valid_update = activity.modified + return activity + if self._previous_data and self._previous_data.date == today: + return self._previous_data + return None diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1530054ad69..0d841c4bb2c 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -3,8 +3,9 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from datetime import datetime -from aiowithings import Goals, MeasurementType, SleepSummary +from aiowithings import Activity, Goals, MeasurementType, SleepSummary from homeassistant.components.sensor import ( SensorDeviceClass, @@ -15,6 +16,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, + Platform, UnitOfLength, UnitOfMass, UnitOfSpeed, @@ -23,7 +25,9 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import StateType +from homeassistant.util import dt as dt_util from . import WithingsData from .const import ( @@ -35,6 +39,7 @@ from .const import ( UOM_MMHG, ) from .coordinator import ( + WithingsActivityDataUpdateCoordinator, WithingsDataUpdateCoordinator, WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, @@ -396,6 +401,105 @@ SLEEP_SENSORS = [ ] +@dataclass +class WithingsActivitySensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Activity], StateType] + + +@dataclass +class WithingsActivitySensorEntityDescription( + SensorEntityDescription, WithingsActivitySensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +ACTIVITY_SENSORS = [ + WithingsActivitySensorEntityDescription( + key="activity_steps_today", + value_fn=lambda activity: activity.steps, + translation_key="activity_steps_today", + icon="mdi:shoe-print", + native_unit_of_measurement="Steps", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_distance_today", + value_fn=lambda activity: activity.distance, + translation_key="activity_distance_today", + suggested_display_precision=0, + icon="mdi:map-marker-distance", + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_floors_climbed_today", + value_fn=lambda activity: activity.floors_climbed, + translation_key="activity_floors_climbed_today", + icon="mdi:stairs-up", + native_unit_of_measurement="Floors", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_soft_duration_today", + value_fn=lambda activity: activity.soft_activity, + translation_key="activity_soft_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_moderate_duration_today", + value_fn=lambda activity: activity.moderate_activity, + translation_key="activity_moderate_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_intense_duration_today", + value_fn=lambda activity: activity.intense_activity, + translation_key="activity_intense_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + entity_registry_enabled_default=False, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_duration_today", + value_fn=lambda activity: activity.total_time_active, + translation_key="activity_active_duration_today", + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.HOURS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_active_calories_burnt_today", + value_fn=lambda activity: activity.active_calories_burnt, + suggested_display_precision=1, + translation_key="activity_active_calories_burnt_today", + native_unit_of_measurement="Calories", + state_class=SensorStateClass.TOTAL, + ), + WithingsActivitySensorEntityDescription( + key="activity_total_calories_burnt_today", + value_fn=lambda activity: activity.total_calories_burnt, + suggested_display_precision=1, + translation_key="activity_total_calories_burnt_today", + native_unit_of_measurement="Calories", + state_class=SensorStateClass.TOTAL, + ), +] + + STEP_GOAL = "steps" SLEEP_GOAL = "sleep" WEIGHT_GOAL = "weight" @@ -460,6 +564,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] measurement_coordinator = withings_data.measurement_coordinator @@ -512,6 +618,31 @@ async def async_setup_entry( goals_coordinator.async_add_listener(_async_goals_listener) + activity_coordinator = withings_data.activity_coordinator + + activity_callback: Callable[[], None] | None = None + + activity_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_activity_steps_today" + ) + + def _async_add_activity_entities() -> None: + """Add activity entities.""" + if activity_coordinator.data is not None or activity_entities_setup_before: + async_add_entities( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + if activity_callback: + activity_callback() + + if activity_coordinator.data is not None or activity_entities_setup_before: + _async_add_activity_entities() + else: + activity_callback = activity_coordinator.async_add_listener( + _async_add_activity_entities + ) + sleep_coordinator = withings_data.sleep_coordinator entities.extend( @@ -585,3 +716,23 @@ class WithingsGoalsSensor(WithingsSensor): """Return the state of the entity.""" assert self.coordinator.data return self.entity_description.value_fn(self.coordinator.data) + + +class WithingsActivitySensor(WithingsSensor): + """Implementation of a Withings activity sensor.""" + + coordinator: WithingsActivityDataUpdateCoordinator + + entity_description: WithingsActivitySensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) + + @property + def last_reset(self) -> datetime: + """These values reset every day.""" + return dt_util.start_of_local_day() diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index fcb94d6979a..a6a832d8394 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -143,6 +143,33 @@ }, "weight_goal": { "name": "Weight goal" + }, + "activity_steps_today": { + "name": "Steps today" + }, + "activity_distance_today": { + "name": "Distance travelled today" + }, + "activity_floors_climbed_today": { + "name": "Floors climbed today" + }, + "activity_soft_duration_today": { + "name": "Soft activity today" + }, + "activity_moderate_duration_today": { + "name": "Moderate activity today" + }, + "activity_intense_duration_today": { + "name": "Intense activity today" + }, + "activity_active_duration_today": { + "name": "Active time today" + }, + "activity_active_calories_burnt_today": { + "name": "Active calories burnt today" + }, + "activity_total_calories_burnt_today": { + "name": "Total calories burnt today" } } } diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 2425a5bd600..56bee0c30db 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Goals, MeasurementGroup +from aiowithings import Activity, Goals, MeasurementGroup from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -84,3 +84,11 @@ def load_measurements_fixture( """Return measurement from fixture.""" meas_json = load_json_array_fixture(fixture) return [MeasurementGroup.from_api(measurement) for measurement in meas_json] + + +def load_activity_fixture( + fixture: str = "withings/activity.json", +) -> list[Activity]: + """Return measurement from fixture.""" + activity_json = load_json_array_fixture(fixture) + return [Activity.from_api(activity) for activity in activity_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 8a824d84917..066a9eed031 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -16,7 +16,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, load_json_array_fixture -from tests.components.withings import load_goals_fixture, load_measurements_fixture +from tests.components.withings import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, +) CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -132,7 +136,7 @@ def mock_withings(): devices_json = load_json_array_fixture("withings/devices.json") devices = [Device.from_api(device) for device in devices_json] - measurement_groups = load_measurements_fixture("withings/measurements.json") + measurement_groups = load_measurements_fixture() sleep_json = load_json_array_fixture("withings/sleep_summaries.json") sleep_summaries = [ @@ -144,12 +148,16 @@ def mock_withings(): NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] + activities = load_activity_fixture() + mock = AsyncMock(spec=WithingsClient) mock.get_devices.return_value = devices - mock.get_goals.return_value = load_goals_fixture("withings/goals.json") + mock.get_goals.return_value = load_goals_fixture() mock.get_measurement_in_period.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups mock.get_sleep_summary_since.return_value = sleep_summaries + mock.get_activities_since.return_value = activities + mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications with patch( diff --git a/tests/components/withings/fixtures/activity.json b/tests/components/withings/fixtures/activity.json new file mode 100644 index 00000000000..8ba9f526afa --- /dev/null +++ b/tests/components/withings/fixtures/activity.json @@ -0,0 +1,282 @@ +[ + { + "steps": 1892, + "distance": 1607.93, + "elevation": 0, + "soft": 4981, + "moderate": 158, + "intense": 0, + "active": 158, + "calories": 204.796, + "totalcalories": 2454.481, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-08", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2576, + "distance": 2349.617, + "elevation": 0, + "soft": 1255, + "moderate": 1211, + "intense": 0, + "active": 1211, + "calories": 134.967, + "totalcalories": 2351.652, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-09", + "modified": 1697038118, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1827, + "distance": 1595.537, + "elevation": 0, + "soft": 2194, + "moderate": 569, + "intense": 0, + "active": 569, + "calories": 110.223, + "totalcalories": 2313.98, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-10", + "modified": 1697057517, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 3801, + "distance": 3307.985, + "elevation": 0, + "soft": 5146, + "moderate": 963, + "intense": 0, + "active": 963, + "calories": 240.89, + "totalcalories": 2385.746, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-11", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2501, + "distance": 2158.186, + "elevation": 0, + "soft": 1854, + "moderate": 998, + "intense": 0, + "active": 998, + "calories": 113.123, + "totalcalories": 2317.396, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-12", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 6787, + "distance": 6008.779, + "elevation": 0, + "soft": 3773, + "moderate": 2831, + "intense": 36, + "active": 2867, + "calories": 263.371, + "totalcalories": 2380.669, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-13", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1232, + "distance": 1050.925, + "elevation": 0, + "soft": 2950, + "moderate": 196, + "intense": 0, + "active": 196, + "calories": 124.754, + "totalcalories": 2311.674, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-14", + "modified": 1697842183, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 851, + "distance": 723.139, + "elevation": 0, + "soft": 1634, + "moderate": 83, + "intense": 0, + "active": 83, + "calories": 68.121, + "totalcalories": 2294.325, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-15", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 654, + "distance": 557.509, + "elevation": 0, + "soft": 1558, + "moderate": 124, + "intense": 0, + "active": 124, + "calories": 66.707, + "totalcalories": 2292.897, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-16", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 566, + "distance": 482.185, + "elevation": 0, + "soft": 1085, + "moderate": 52, + "intense": 0, + "active": 52, + "calories": 45.126, + "totalcalories": 2287.08, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-17", + "modified": 1697842184, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 2204, + "distance": 1901.651, + "elevation": 0, + "soft": 1393, + "moderate": 941, + "intense": 0, + "active": 941, + "calories": 92.585, + "totalcalories": 2302.971, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-18", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 95, + "distance": 80.63, + "elevation": 0, + "soft": 543, + "moderate": 0, + "intense": 0, + "active": 0, + "calories": 21.541, + "totalcalories": 2277.668, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-19", + "modified": 1697842185, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1209, + "distance": 1028.559, + "elevation": 0, + "soft": 1864, + "moderate": 292, + "intense": 0, + "active": 292, + "calories": 85.497, + "totalcalories": 2303.788, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-20", + "modified": 1697884856, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + }, + { + "steps": 1155, + "distance": 1020.121, + "elevation": 0, + "soft": 1516, + "moderate": 1487, + "intense": 420, + "active": 1907, + "calories": 221.132, + "totalcalories": 2444.149, + "deviceid": null, + "hash_deviceid": null, + "timezone": "Europe/Amsterdam", + "date": "2023-10-21", + "modified": 1697888004, + "brand": 18, + "modelid": 1055, + "model": "GoogleFit tracker", + "is_tracker": false + } +] diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 3546a24d2fe..cf8ff0a462b 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -1,4 +1,35 @@ # serializer version: 1 +# name: test_all_entities[sensor.henk_active_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Active calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Calories', + }), + 'context': , + 'entity_id': 'sensor.henk_active_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '221.132', + }) +# --- +# name: test_all_entities[sensor.henk_active_time_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Active time today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_active_time_today', + 'last_changed': , + 'last_updated': , + 'state': '1907', + }) +# --- # name: test_all_entities[sensor.henk_average_heart_rate] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -102,6 +133,23 @@ 'state': '70', }) # --- +# name: test_all_entities[sensor.henk_distance_travelled_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled today', + 'icon': 'mdi:map-marker-distance', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_today', + 'last_changed': , + 'last_updated': , + 'state': '1020.121', + }) +# --- # name: test_all_entities[sensor.henk_extracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -161,6 +209,22 @@ 'state': '0.07', }) # --- +# name: test_all_entities[sensor.henk_floors_climbed_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed today', + 'icon': 'mdi:stairs-up', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_today', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_heart_pulse] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -207,6 +271,22 @@ 'state': '0.95', }) # --- +# name: test_all_entities[sensor.henk_intense_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Intense activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_intense_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '420', + }) +# --- # name: test_all_entities[sensor.henk_intracellular_water] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -296,6 +376,22 @@ 'state': '10', }) # --- +# name: test_all_entities[sensor.henk_moderate_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Moderate activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_moderate_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1487', + }) +# --- # name: test_all_entities[sensor.henk_muscle_mass] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -414,6 +510,22 @@ 'state': '87', }) # --- +# name: test_all_entities[sensor.henk_soft_activity_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Soft activity today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_soft_activity_today', + 'last_changed': , + 'last_updated': , + 'state': '1516', + }) +# --- # name: test_all_entities[sensor.henk_spo2] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -443,6 +555,22 @@ 'state': '10000', }) # --- +# name: test_all_entities[sensor.henk_steps_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Steps today', + 'icon': 'mdi:shoe-print', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Steps', + }), + 'context': , + 'entity_id': 'sensor.henk_steps_today', + 'last_changed': , + 'last_updated': , + 'state': '1155', + }) +# --- # name: test_all_entities[sensor.henk_systolic_blood_pressure] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -504,6 +632,21 @@ 'state': '996', }) # --- +# name: test_all_entities[sensor.henk_total_calories_burnt_today] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Total calories burnt today', + 'last_reset': '2023-10-20T00:00:00-07:00', + 'state_class': , + 'unit_of_measurement': 'Calories', + }), + 'context': , + 'entity_id': 'sensor.henk_total_calories_burnt_today', + 'last_changed': , + 'last_updated': , + 'state': '2444.149', + }) +# --- # name: test_all_entities[sensor.henk_vascular_age] StateSnapshot({ 'attributes': ReadOnlyDict({ diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6cf33c45c9d..1acfc324d81 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -6,15 +6,21 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion -from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import load_goals_fixture, load_measurements_fixture, setup_integration +from . import ( + load_activity_fixture, + load_goals_fixture, + load_measurements_fixture, + setup_integration, +) from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.freeze_time("2023-10-21") @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_all_entities( hass: HomeAssistant, @@ -115,9 +121,7 @@ async def test_update_new_measurement_creates_new_sensor( assert hass.states.get("sensor.henk_fat_mass") is None - withings.get_measurement_in_period.return_value = load_measurements_fixture( - "withings/measurements.json" - ) + withings.get_measurement_in_period.return_value = load_measurements_fixture() freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) @@ -141,10 +145,101 @@ async def test_update_new_goals_creates_new_sensor( assert hass.states.get("sensor.henk_step_goal") is None assert hass.states.get("sensor.henk_weight_goal") is not None - withings.get_goals.return_value = load_goals_fixture("withings/goals.json") + withings.get_goals.return_value = load_goals_fixture() freezer.tick(timedelta(hours=1)) async_fire_time_changed(hass) await hass.async_block_till_done() assert hass.states.get("sensor.henk_step_goal") is not None + + +async def test_activity_sensors_unknown_next_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return unknown the next day.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is not None + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_same_result_same_day( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will return the same result if old data is updated.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + withings.get_activities_since.return_value = [] + + freezer.tick(timedelta(hours=2)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == "1155" + + +async def test_activity_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if they existed before.""" + freezer.move_to("2023-10-21") + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is not None + assert hass.states.get("sensor.henk_steps_today").state != STATE_UNKNOWN + + withings.get_activities_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today").state == STATE_UNKNOWN + + +async def test_activity_sensors_created_when_receive_activity_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test activity sensors will be added if we receive activity data.""" + freezer.move_to("2023-10-21") + withings.get_activities_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_steps_today") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") is None + + withings.get_activities_in_period.return_value = load_activity_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_steps_today") is not None From a97e34f28ebcc4eb618e68f500b6e30b93d6f3f9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 22 Oct 2023 23:50:40 +0200 Subject: [PATCH 714/968] Add Nephelometry sensor to waqi (#102298) --- homeassistant/components/waqi/sensor.py | 8 ++++++++ homeassistant/components/waqi/strings.json | 3 +++ .../waqi/fixtures/air_quality_sensor.json | 3 +++ tests/components/waqi/snapshots/test_sensor.ambr | 14 ++++++++++++++ tests/components/waqi/test_sensor.py | 4 +++- 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index ecc29006c5d..d94a2e19f67 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -240,6 +240,14 @@ SENSORS: list[WAQISensorEntityDescription] = [ value_fn=lambda aq: aq.extended_air_quality.pm25, available_fn=lambda aq: aq.extended_air_quality.pm25 is not None, ), + WAQISensorEntityDescription( + key="neph", + translation_key="neph", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda aq: aq.extended_air_quality.nephelometry, + available_fn=lambda aq: aq.extended_air_quality.nephelometry is not None, + entity_registry_enabled_default=False, + ), WAQISensorEntityDescription( key="dominant_pollutant", translation_key="dominant_pollutant", diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json index 54013f3ca2c..de287318508 100644 --- a/homeassistant/components/waqi/strings.json +++ b/homeassistant/components/waqi/strings.json @@ -74,6 +74,9 @@ "pm25": { "name": "[%key:component::sensor::entity_component::pm25::name%]" }, + "neph": { + "name": "Visbility using nephelometry" + }, "dominant_pollutant": { "name": "Dominant pollutant", "state": { diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json index fbc153e4e28..885ecd8dfa2 100644 --- a/tests/components/waqi/fixtures/air_quality_sensor.json +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -23,6 +23,9 @@ "h": { "v": 80 }, + "neph": { + "v": 80 + }, "co": { "v": 2.3 }, diff --git a/tests/components/waqi/snapshots/test_sensor.ambr b/tests/components/waqi/snapshots/test_sensor.ambr index 3d4d7f30bbd..029b36b3c16 100644 --- a/tests/components/waqi/snapshots/test_sensor.ambr +++ b/tests/components/waqi/snapshots/test_sensor.ambr @@ -41,6 +41,20 @@ }) # --- # name: test_sensor.10 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', + 'friendly_name': 'de Jongweg, Utrecht Visbility using nephelometry', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.de_jongweg_utrecht_visbility_using_nephelometry', + 'last_changed': , + 'last_updated': , + 'state': '80', + }) +# --- +# name: test_sensor.11 StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project', diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py index 3d708e6c26d..ebe0c87736d 100644 --- a/tests/components/waqi/test_sensor.py +++ b/tests/components/waqi/test_sensor.py @@ -3,6 +3,7 @@ import json from unittest.mock import patch from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult +import pytest from syrupy import SnapshotAssertion from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -115,12 +116,13 @@ async def test_sensor_id_migration( entities = er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - assert len(entities) == 11 + assert len(entities) == 12 assert hass.states.get("sensor.waqi_4584") assert hass.states.get("sensor.de_jongweg_utrecht_air_quality_index") is None assert entities[0].unique_id == "4584_air_quality" +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor( hass: HomeAssistant, mock_config_entry: MockConfigEntry, snapshot: SnapshotAssertion ) -> None: From b980ed3eacc35273d48476895e935c64634cf99f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 11:55:13 -1000 Subject: [PATCH 715/968] Avoid more device_class lookups for number entities when writing state (#102381) --- homeassistant/components/number/__init__.py | 26 +++++++++++++++------ tests/components/number/test_init.py | 25 ++++++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 97221aaca90..201fa8fedb6 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -218,8 +218,13 @@ class NumberEntity(Entity): @property def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" - min_value = self.min_value - max_value = self.max_value + device_class = self.device_class + min_value = self._convert_to_state_value( + self.native_min_value, floor_decimal, device_class + ) + max_value = self._convert_to_state_value( + self.native_max_value, ceil_decimal, device_class + ) return { ATTR_MIN: min_value, ATTR_MAX: max_value, @@ -259,7 +264,9 @@ class NumberEntity(Entity): @final def min_value(self) -> float: """Return the minimum value.""" - return self._convert_to_state_value(self.native_min_value, floor_decimal) + return self._convert_to_state_value( + self.native_min_value, floor_decimal, self.device_class + ) @property def native_max_value(self) -> float: @@ -277,7 +284,9 @@ class NumberEntity(Entity): @final def max_value(self) -> float: """Return the maximum value.""" - return self._convert_to_state_value(self.native_max_value, ceil_decimal) + return self._convert_to_state_value( + self.native_max_value, ceil_decimal, self.device_class + ) @property def native_step(self) -> float | None: @@ -365,7 +374,7 @@ class NumberEntity(Entity): """Return the entity value to represent the entity state.""" if (native_value := self.native_value) is None: return native_value - return self._convert_to_state_value(native_value, round) + return self._convert_to_state_value(native_value, round, self.device_class) def set_native_value(self, value: float) -> None: """Set new value.""" @@ -386,12 +395,15 @@ class NumberEntity(Entity): await self.hass.async_add_executor_job(self.set_value, value) def _convert_to_state_value( - self, value: float, method: Callable[[float, int], float] + self, + value: float, + method: Callable[[float, int], float], + device_class: NumberDeviceClass | None, ) -> float: """Convert a value in the number's native unit to the configured unit.""" # device_class is checked first since most of the time we can avoid # the unit conversion - if (device_class := self.device_class) not in UNIT_CONVERTERS: + if device_class not in UNIT_CONVERTERS: return value native_unit_of_measurement = self.native_unit_of_measurement diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 3f612c421c8..601a34d4271 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.number import ( ATTR_MAX, ATTR_MIN, + ATTR_MODE, ATTR_STEP, ATTR_VALUE, DOMAIN, @@ -227,6 +228,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number.step == 1.0 assert number.unit_of_measurement is None assert number.value == 0.5 + assert number.capability_attributes == { + ATTR_MAX: 100.0, + ATTR_MIN: 0.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 1.0, + } number_2 = MockNumberEntity() number_2.hass = hass @@ -235,6 +242,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_2.step == 0.1 assert number_2.unit_of_measurement == "native_cats" assert number_2.value == 0.5 + assert number_2.capability_attributes == { + ATTR_MAX: 0.5, + ATTR_MIN: -0.5, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 0.1, + } number_3 = MockNumberEntityAttr() number_3.hass = hass @@ -243,6 +256,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_3.step == 100.0 assert number_3.unit_of_measurement == "native_dogs" assert number_3.value == 500.0 + assert number_3.capability_attributes == { + ATTR_MAX: 1000.0, + ATTR_MIN: -1000.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 100.0, + } number_4 = MockNumberEntityDescr() number_4.hass = hass @@ -251,6 +270,12 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_4.step == 2.0 assert number_4.unit_of_measurement == "native_rabbits" assert number_4.value is None + assert number_4.capability_attributes == { + ATTR_MAX: 10.0, + ATTR_MIN: -10.0, + ATTR_MODE: NumberMode.AUTO, + ATTR_STEP: 2.0, + } async def test_sync_set_value(hass: HomeAssistant) -> None: From 4ee9a6f1301d34c2f287038ebf0c3acdc5f93517 Mon Sep 17 00:00:00 2001 From: Seth <48533968+WillCodeForCats@users.noreply.github.com> Date: Sun, 22 Oct 2023 15:02:15 -0700 Subject: [PATCH 716/968] Implement available property for Airthings BLE sensors (#96735) --- homeassistant/components/airthings_ble/sensor.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 28b5fa3a7a6..aaeb91cf30b 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -226,6 +226,14 @@ class AirthingsSensor( model=airthings_device.model, ) + @property + def available(self) -> bool: + """Check if device and sensor is available in data.""" + return ( + super().available + and self.entity_description.key in self.coordinator.data.sensors + ) + @property def native_value(self) -> StateType: """Return the value reported by the sensor.""" From 268425b5e3db07525635b06e3b0c1e2863ae513f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 22 Oct 2023 17:34:43 -1000 Subject: [PATCH 717/968] Recover from previously failed statistics migrations (#101781) * Handle statistics columns being unmigrated from previous downgrades If the user downgraded HA from 2023.3.x to an older version without restoring the database and they upgrade again with the same database they will have unmigrated statistics columns since we only migrate them once. As its expensive to check, we do not want to check every time at startup, so we will only do this one more time since the risk that someone will downgrade to an older version is very low at this point. * add guard to sqlite to prevent re-migrate * test * move test to insert with old schema * use helper * normalize timestamps * remove * add check * add fallback migration * add fallback migration * commit * remove useless logging * remove useless logging * do the other columns at the same time * coverage * dry * comment * Update tests/components/recorder/test_migration_from_schema_32.py --- .../components/recorder/db_schema.py | 2 +- .../components/recorder/migration.py | 130 +++- homeassistant/components/recorder/queries.py | 94 +++ tests/components/recorder/common.py | 15 +- .../recorder/test_migration_from_schema_32.py | 587 +++++++++++++++++- .../recorder/test_purge_v32_schema.py | 44 +- 6 files changed, 819 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 17e34af1e11..06c8cf68903 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -68,7 +68,7 @@ class Base(DeclarativeBase): """Base class for tables.""" -SCHEMA_VERSION = 41 +SCHEMA_VERSION = 42 _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 7655002b45f..8808ed2fd2b 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -68,13 +68,20 @@ from .db_schema import ( StatisticsShortTerm, ) from .models import process_timestamp +from .models.time import datetime_to_timestamp_or_none from .queries import ( batch_cleanup_entity_ids, + delete_duplicate_short_term_statistics_row, + delete_duplicate_statistics_row, find_entity_ids_to_migrate, find_event_type_to_migrate, find_events_context_ids_to_migrate, find_states_context_ids_to_migrate, + find_unmigrated_short_term_statistics_rows, + find_unmigrated_statistics_rows, has_used_states_event_ids, + migrate_single_short_term_statistics_row_to_timestamp, + migrate_single_statistics_row_to_timestamp, ) from .statistics import get_start_time from .tasks import ( @@ -950,26 +957,9 @@ def _apply_update( # noqa: C901 "statistics_short_term", "ix_statistics_short_term_statistic_id_start_ts", ) - try: - _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) - except IntegrityError as ex: - _LOGGER.error( - "Statistics table contains duplicate entries: %s; " - "Cleaning up duplicates and trying again; " - "This will take a while; " - "Please be patient!", - ex, - ) - # There may be duplicated statistics entries, delete duplicates - # and try again - with session_scope(session=session_maker()) as session: - delete_statistics_duplicates(instance, hass, session) - _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) - # Log at error level to ensure the user sees this message in the log - # since we logged the error above. - _LOGGER.error( - "Statistics migration successfully recovered after statistics table duplicate cleanup" - ) + _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, session_maker, engine + ) elif new_version == 35: # Migration is done in two steps to ensure we can start using # the new columns before we wipe the old ones. @@ -1060,10 +1050,55 @@ def _apply_update( # noqa: C901 elif new_version == 41: _create_index(session_maker, "event_types", "ix_event_types_event_type") _create_index(session_maker, "states_meta", "ix_states_meta_entity_id") + elif new_version == 42: + # If the user had a previously failed migration, or they + # downgraded from 2023.3.x to an older version we will have + # unmigrated statistics columns so we want to clean this up + # one last time since compiling the statistics will be slow + # or fail if we have unmigrated statistics. + _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, session_maker, engine + ) else: raise ValueError(f"No schema migration defined for version {new_version}") +def _migrate_statistics_columns_to_timestamp_removing_duplicates( + hass: HomeAssistant, + instance: Recorder, + session_maker: Callable[[], Session], + engine: Engine, +) -> None: + """Migrate statistics columns to timestamp or cleanup duplicates.""" + try: + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + except IntegrityError as ex: + _LOGGER.error( + "Statistics table contains duplicate entries: %s; " + "Cleaning up duplicates and trying again; " + "This will take a while; " + "Please be patient!", + ex, + ) + # There may be duplicated statistics entries, delete duplicates + # and try again + with session_scope(session=session_maker()) as session: + delete_statistics_duplicates(instance, hass, session) + try: + _migrate_statistics_columns_to_timestamp(instance, session_maker, engine) + except IntegrityError: + _LOGGER.warning( + "Statistics table still contains duplicate entries after cleanup; " + "Falling back to a one by one migration" + ) + _migrate_statistics_columns_to_timestamp_one_by_one(instance, session_maker) + # Log at error level to ensure the user sees this message in the log + # since we logged the error above. + _LOGGER.error( + "Statistics migration successfully recovered after statistics table duplicate cleanup" + ) + + def _correct_table_character_set_and_collation( table: str, session_maker: Callable[[], Session], @@ -1269,6 +1304,59 @@ def _migrate_columns_to_timestamp( ) +@database_job_retry_wrapper("Migrate statistics columns to timestamp one by one", 3) +def _migrate_statistics_columns_to_timestamp_one_by_one( + instance: Recorder, session_maker: Callable[[], Session] +) -> None: + """Migrate statistics columns to use timestamp on by one. + + If something manually inserted data into the statistics table + in the past it may have inserted duplicate rows. + + Before we had the unique index on (statistic_id, start) this + the data could have been inserted without any errors and we + could end up with duplicate rows that go undetected (even by + our current duplicate cleanup code) until we try to migrate the + data to use timestamps. + + This will migrate the data one by one to ensure we do not hit any + duplicate rows, and remove the duplicate rows as they are found. + """ + for find_func, migrate_func, delete_func in ( + ( + find_unmigrated_statistics_rows, + migrate_single_statistics_row_to_timestamp, + delete_duplicate_statistics_row, + ), + ( + find_unmigrated_short_term_statistics_rows, + migrate_single_short_term_statistics_row_to_timestamp, + delete_duplicate_short_term_statistics_row, + ), + ): + with session_scope(session=session_maker()) as session: + while stats := session.execute(find_func(instance.max_bind_vars)).all(): + for statistic_id, start, created, last_reset in stats: + start_ts = datetime_to_timestamp_or_none(process_timestamp(start)) + created_ts = datetime_to_timestamp_or_none( + process_timestamp(created) + ) + last_reset_ts = datetime_to_timestamp_or_none( + process_timestamp(last_reset) + ) + try: + session.execute( + migrate_func( + statistic_id, start_ts, created_ts, last_reset_ts + ) + ) + except IntegrityError: + # This can happen if we have duplicate rows + # in the statistics table. + session.execute(delete_func(statistic_id)) + session.commit() + + @database_job_retry_wrapper("Migrate statistics columns to timestamp", 3) def _migrate_statistics_columns_to_timestamp( instance: Recorder, session_maker: Callable[[], Session], engine: Engine @@ -1292,7 +1380,7 @@ def _migrate_statistics_columns_to_timestamp( f"created_ts=strftime('%s',created) + " "cast(substr(created,-7) AS FLOAT), " f"last_reset_ts=strftime('%s',last_reset) + " - "cast(substr(last_reset,-7) AS FLOAT);" + "cast(substr(last_reset,-7) AS FLOAT) where start_ts is NULL;" ) ) elif engine.dialect.name == SupportedDialect.MYSQL: diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index d44094878c2..c03057b31b2 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -16,6 +16,7 @@ from .db_schema import ( StateAttributes, States, StatesMeta, + Statistics, StatisticsRuns, StatisticsShortTerm, ) @@ -860,3 +861,96 @@ def delete_states_meta_rows(metadata_ids: Iterable[int]) -> StatementLambdaEleme .where(StatesMeta.metadata_id.in_(metadata_ids)) .execution_options(synchronize_session=False) ) + + +def find_unmigrated_short_term_statistics_rows( + max_bind_vars: int, +) -> StatementLambdaElement: + """Find unmigrated short term statistics rows.""" + return lambda_stmt( + lambda: select( + StatisticsShortTerm.id, + StatisticsShortTerm.start, + StatisticsShortTerm.created, + StatisticsShortTerm.last_reset, + ) + .filter(StatisticsShortTerm.start_ts.is_(None)) + .filter(StatisticsShortTerm.start.isnot(None)) + .limit(max_bind_vars) + ) + + +def find_unmigrated_statistics_rows(max_bind_vars: int) -> StatementLambdaElement: + """Find unmigrated statistics rows.""" + return lambda_stmt( + lambda: select( + Statistics.id, Statistics.start, Statistics.created, Statistics.last_reset + ) + .filter(Statistics.start_ts.is_(None)) + .filter(Statistics.start.isnot(None)) + .limit(max_bind_vars) + ) + + +def migrate_single_short_term_statistics_row_to_timestamp( + statistic_id: int, + start_ts: float | None, + created_ts: float | None, + last_reset_ts: float | None, +) -> StatementLambdaElement: + """Migrate a single short term statistics row to timestamp.""" + return lambda_stmt( + lambda: update(StatisticsShortTerm) + .where(StatisticsShortTerm.id == statistic_id) + .values( + start_ts=start_ts, + start=None, + created_ts=created_ts, + created=None, + last_reset_ts=last_reset_ts, + last_reset=None, + ) + .execution_options(synchronize_session=False) + ) + + +def migrate_single_statistics_row_to_timestamp( + statistic_id: int, + start_ts: float | None, + created_ts: float | None, + last_reset_ts: float | None, +) -> StatementLambdaElement: + """Migrate a single statistics row to timestamp.""" + return lambda_stmt( + lambda: update(Statistics) + .where(Statistics.id == statistic_id) + .values( + start_ts=start_ts, + start=None, + created_ts=created_ts, + created=None, + last_reset_ts=last_reset_ts, + last_reset=None, + ) + .execution_options(synchronize_session=False) + ) + + +def delete_duplicate_short_term_statistics_row( + statistic_id: int, +) -> StatementLambdaElement: + """Delete a single duplicate short term statistics row.""" + return lambda_stmt( + lambda: delete(StatisticsShortTerm) + .where(StatisticsShortTerm.id == statistic_id) + .execution_options(synchronize_session=False) + ) + + +def delete_duplicate_statistics_row(statistic_id: int) -> StatementLambdaElement: + """Delete a single duplicate statistics row.""" + return lambda_stmt( + lambda: delete(Statistics) + .where(Statistics.id == statistic_id) + .execution_options(synchronize_session=False) + ) diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 521be81c89b..a982eeb39be 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -11,7 +11,7 @@ import importlib import sys import time from typing import Any, Literal, cast -from unittest.mock import patch, sentinel +from unittest.mock import MagicMock, patch, sentinel from freezegun import freeze_time from sqlalchemy import create_engine @@ -430,3 +430,16 @@ def old_db_schema(schema_version_postfix: str) -> Iterator[None]: ), ): yield + + +async def async_attach_db_engine(hass: HomeAssistant) -> None: + """Attach a database engine to the recorder.""" + instance = recorder.get_instance(hass) + + def _mock_setup_recorder_connection(): + with instance.engine.connect() as connection: + instance._setup_recorder_connection( + connection._dbapi_connection, MagicMock() + ) + + await instance.async_add_executor_job(_mock_setup_recorder_connection) diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index e007d2408dd..852419559b2 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -1,22 +1,26 @@ """The tests for the recorder filter matching the EntityFilter component.""" +import datetime import importlib import sys +from typing import Any from unittest.mock import patch import uuid from freezegun import freeze_time import pytest from sqlalchemy import create_engine, inspect +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from homeassistant.components import recorder -from homeassistant.components.recorder import core, migration, statistics +from homeassistant.components.recorder import core, db_schema, migration, statistics from homeassistant.components.recorder.db_schema import ( Events, EventTypes, States, StatesMeta, ) +from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.queries import select_event_type_ids from homeassistant.components.recorder.tasks import ( EntityIDMigrationTask, @@ -30,7 +34,11 @@ from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from homeassistant.util.ulid import bytes_to_ulid, ulid_at_time, ulid_to_bytes -from .common import async_recorder_block_till_done, async_wait_recording_done +from .common import ( + async_attach_db_engine, + async_recorder_block_till_done, + async_wait_recording_done, +) from tests.typing import RecorderInstanceGenerator @@ -844,3 +852,578 @@ async def test_migrate_null_event_type_ids( events_by_type = await instance.async_add_executor_job(_fetch_migrated_events) assert len(events_by_type["event_type_one"]) == 2 assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000 + + +async def test_stats_timestamp_conversion_is_reentrant( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration is reentrant.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ) + ) + + def _get_all_short_term_stats() -> list[dict[str, Any]]: + with session_scope(hass=hass) as session: + results = [] + for result in ( + session.query(old_db_schema.StatisticsShortTerm) + .where(old_db_schema.StatisticsShortTerm.metadata_id == 1000) + .all() + ): + results.append( + { + field.name: getattr(result, field.name) + for field in old_db_schema.StatisticsShortTerm.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + # Do not optimize this block, its intentionally written to interleave + # with the migration + await hass.async_add_executor_job(_insert_fake_metadata) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_insert_pre_timestamp_stat, one_year_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + await hass.async_add_executor_job(_insert_post_timestamp_stat, six_months_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + await hass.async_add_executor_job(_insert_pre_timestamp_stat, one_month_ago) + await async_wait_recording_done(hass) + await hass.async_add_executor_job(_do_migration) + + final_result = await hass.async_add_executor_job(_get_all_short_term_stats) + # Normalize timestamps since each engine returns them differently + for row in final_result: + if row["created"] is not None: + row["created"] = process_timestamp(row["created"]).replace(tzinfo=None) + if row["start"] is not None: + row["start"] = process_timestamp(row["start"]).replace(tzinfo=None) + if row["last_reset"] is not None: + row["last_reset"] = process_timestamp(row["last_reset"]).replace( + tzinfo=None + ) + + assert final_result == [ + { + "created": process_timestamp(one_year_ago).replace(tzinfo=None), + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": process_timestamp(one_year_ago).replace(tzinfo=None), + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": process_timestamp(one_year_ago).replace(tzinfo=None), + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": process_timestamp(one_month_ago).replace(tzinfo=None), + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": process_timestamp(one_month_ago).replace(tzinfo=None), + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": process_timestamp(one_month_ago).replace(tzinfo=None), + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + +async def test_stats_timestamp_with_one_by_one( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration with one by one.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + with patch.object( + migration, + "_migrate_statistics_columns_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + old_db_schema.Statistics( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + db_schema.Statistics( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + ) + ) + + def _get_all_stats(table: old_db_schema.StatisticsBase) -> list[dict[str, Any]]: + """Get all stats from a table.""" + with session_scope(hass=hass) as session: + results = [] + for result in session.query(table).where(table.metadata_id == 1000).all(): + results.append( + { + field.name: getattr(result, field.name) + for field in table.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + def _insert_and_do_migration(): + _insert_fake_metadata() + _insert_pre_timestamp_stat(one_year_ago) + _insert_post_timestamp_stat(six_months_ago) + _insert_pre_timestamp_stat(one_month_ago) + _do_migration() + + await hass.async_add_executor_job(_insert_and_do_migration) + final_short_term_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.StatisticsShortTerm + ) + final_short_term_result = sorted( + final_short_term_result, key=lambda row: row["start_ts"] + ) + + assert final_short_term_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + final_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.Statistics + ) + final_result = sorted(final_result, key=lambda row: row["start_ts"]) + + assert final_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + +async def test_stats_timestamp_with_one_by_one_removes_duplicates( + async_setup_recorder_instance: RecorderInstanceGenerator, + hass: HomeAssistant, +) -> None: + """Test stats migration with one by one removes duplicates.""" + instance = await async_setup_recorder_instance(hass) + await async_wait_recording_done(hass) + await async_attach_db_engine(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] + now = dt_util.utcnow() + one_year_ago = now - datetime.timedelta(days=365) + six_months_ago = now - datetime.timedelta(days=180) + one_month_ago = now - datetime.timedelta(days=30) + + def _do_migration(): + with patch.object( + migration, + "_migrate_statistics_columns_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ), patch.object( + migration, + "migrate_single_statistics_row_to_timestamp", + side_effect=IntegrityError("test", "test", "test"), + ): + migration._migrate_statistics_columns_to_timestamp_removing_duplicates( + hass, instance, instance.get_session, instance.engine + ) + + def _insert_fake_metadata(): + with session_scope(hass=hass) as session: + session.add( + old_db_schema.StatisticsMeta( + id=1000, + statistic_id="test", + source="test", + unit_of_measurement="test", + has_mean=True, + has_sum=True, + name="1", + ) + ) + + def _insert_pre_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + old_db_schema.StatisticsShortTerm( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + old_db_schema.Statistics( + metadata_id=1000, + created=date_time, + created_ts=None, + start=date_time, + start_ts=None, + last_reset=date_time, + last_reset_ts=None, + state="1", + ), + ) + ) + + def _insert_post_timestamp_stat(date_time: datetime) -> None: + with session_scope(hass=hass) as session: + session.add_all( + ( + db_schema.StatisticsShortTerm( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + db_schema.Statistics( + metadata_id=1000, + created=None, + created_ts=date_time.timestamp(), + start=None, + start_ts=date_time.timestamp(), + last_reset=None, + last_reset_ts=date_time.timestamp(), + state="1", + ), + ) + ) + + def _get_all_stats(table: old_db_schema.StatisticsBase) -> list[dict[str, Any]]: + """Get all stats from a table.""" + with session_scope(hass=hass) as session: + results = [] + for result in session.query(table).where(table.metadata_id == 1000).all(): + results.append( + { + field.name: getattr(result, field.name) + for field in table.__table__.c + } + ) + return sorted(results, key=lambda row: row["start_ts"]) + + def _insert_and_do_migration(): + _insert_fake_metadata() + _insert_pre_timestamp_stat(one_year_ago) + _insert_post_timestamp_stat(six_months_ago) + _insert_pre_timestamp_stat(one_month_ago) + _do_migration() + + await hass.async_add_executor_job(_insert_and_do_migration) + final_short_term_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.StatisticsShortTerm + ) + final_short_term_result = sorted( + final_short_term_result, key=lambda row: row["start_ts"] + ) + + assert final_short_term_result == [ + { + "created": None, + "created_ts": one_year_ago.timestamp(), + "id": 1, + "last_reset": None, + "last_reset_ts": one_year_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_year_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + { + "created": None, + "created_ts": one_month_ago.timestamp(), + "id": 3, + "last_reset": None, + "last_reset_ts": one_month_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": one_month_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] + + # All the duplicates should have been removed but + # the non-duplicates should still be there + final_result = await hass.async_add_executor_job( + _get_all_stats, old_db_schema.Statistics + ) + assert final_result == [ + { + "created": None, + "created_ts": six_months_ago.timestamp(), + "id": 2, + "last_reset": None, + "last_reset_ts": six_months_ago.timestamp(), + "max": None, + "mean": None, + "metadata_id": 1000, + "min": None, + "start": None, + "start_ts": six_months_ago.timestamp(), + "state": 1.0, + "sum": None, + }, + ] diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index b3c20ad4e26..f386fd19e36 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta import json import sqlite3 -from unittest.mock import MagicMock, patch +from unittest.mock import patch from freezegun import freeze_time import pytest @@ -27,6 +27,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from .common import ( + async_attach_db_engine, async_recorder_block_till_done, async_wait_purge_done, async_wait_recording_done, @@ -64,25 +65,12 @@ def mock_use_sqlite(request): yield -async def _async_attach_db_engine(hass: HomeAssistant) -> None: - """Attach a database engine to the recorder.""" - instance = recorder.get_instance(hass) - - def _mock_setup_recorder_connection(): - with instance.engine.connect() as connection: - instance._setup_recorder_connection( - connection._dbapi_connection, MagicMock() - ) - - await instance.async_add_executor_job(_mock_setup_recorder_connection) - - async def test_purge_old_states( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: """Test deleting old states.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) @@ -178,7 +166,7 @@ async def test_purge_old_states_encouters_database_corruption( return await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -211,7 +199,7 @@ async def test_purge_old_states_encounters_temporary_mysql_error( ) -> None: """Test retry on specific mysql operational errors.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -243,7 +231,7 @@ async def test_purge_old_states_encounters_operational_error( ) -> None: """Test error on operational errors that are not mysql does not retry.""" await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_states(hass) await async_wait_recording_done(hass) @@ -268,7 +256,7 @@ async def test_purge_old_events( ) -> None: """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_events(hass) @@ -306,7 +294,7 @@ async def test_purge_old_recorder_runs( ) -> None: """Test deleting old recorder runs keeps current run.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_recorder_runs(hass) @@ -343,7 +331,7 @@ async def test_purge_old_statistics_runs( ) -> None: """Test deleting old statistics runs keeps the latest run.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await _add_test_statistics_runs(hass) @@ -384,7 +372,7 @@ async def test_purge_method( assert run1.start == run2.start await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) service_data = {"keep_days": 4} await _add_test_events(hass) @@ -522,7 +510,7 @@ async def test_purge_edge_case( ) await async_setup_recorder_instance(hass, None) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_purge_done(hass) @@ -621,7 +609,7 @@ async def test_purge_cutoff_date( ) instance = await async_setup_recorder_instance(hass, None) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_purge_done(hass) @@ -948,7 +936,7 @@ async def test_purge_many_old_events( ) -> None: """Test deleting old events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) old_events_count = 5 with patch.object(instance, "max_bind_vars", old_events_count), patch.object( @@ -1001,7 +989,7 @@ async def test_purge_can_mix_legacy_and_new_format( ) -> None: """Test purging with legacy and new events.""" instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_recording_done(hass) # New databases are no longer created with the legacy events index @@ -1114,7 +1102,7 @@ async def test_purge_can_mix_legacy_and_new_format_with_detached_state( return pytest.skip("This tests disables foreign key checks on SQLite") instance = await async_setup_recorder_instance(hass) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await async_wait_recording_done(hass) # New databases are no longer created with the legacy events index @@ -1254,7 +1242,7 @@ async def test_purge_entities_keep_days( ) -> None: """Test purging states with an entity filter and keep_days.""" instance = await async_setup_recorder_instance(hass, {}) - await _async_attach_db_engine(hass) + await async_attach_db_engine(hass) await hass.async_block_till_done() await async_wait_recording_done(hass) From 54ba376b4b2b5074ef12448e0e6805fa0b5a3d11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Oct 2023 09:01:58 +0200 Subject: [PATCH 718/968] Make Withings bed presence sensor dynamic (#102058) * Make Withings bed presence sensor dynamic * Make Withings bed presence sensor dynamic * Update homeassistant/components/withings/binary_sensor.py Co-authored-by: J. Nick Koston --------- Co-authored-by: J. Nick Koston --- .../components/withings/binary_sensor.py | 21 +++++++++++++++++-- .../components/withings/test_binary_sensor.py | 14 +++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 69af68e988b..1317befcf3f 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -1,13 +1,17 @@ """Sensors flow for Withings.""" from __future__ import annotations +from collections.abc import Callable + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import WithingsBedPresenceDataUpdateCoordinator @@ -22,9 +26,22 @@ async def async_setup_entry( """Set up the sensor config entry.""" coordinator = hass.data[DOMAIN][entry.entry_id].bed_presence_coordinator - entities = [WithingsBinarySensor(coordinator)] + ent_reg = er.async_get(hass) - async_add_entities(entities) + callback: Callable[[], None] | None = None + + def _async_add_bed_presence_entity() -> None: + """Add bed presence entity.""" + async_add_entities([WithingsBinarySensor(coordinator)]) + if callback: + callback() + + if ent_reg.async_get_entity_id( + Platform.BINARY_SENSOR, DOMAIN, f"withings_{entry.unique_id}_in_bed" + ): + _async_add_bed_presence_entity() + else: + callback = coordinator.async_add_listener(_async_add_bed_presence_entity) class WithingsBinarySensor(WithingsEntity, BinarySensorEntity): diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 5054bf46daa..c56b14ae893 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -22,6 +22,7 @@ async def test_binary_sensor( webhook_config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, freezer: FrozenDateTimeFactory, + caplog: pytest.LogCaptureFixture, ) -> None: """Test binary sensor.""" await setup_integration(hass, webhook_config_entry) @@ -31,7 +32,7 @@ async def test_binary_sensor( entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id) is None resp = await call_webhook( hass, @@ -53,6 +54,15 @@ async def test_binary_sensor( await hass.async_block_till_done() assert hass.states.get(entity_id).state == STATE_OFF + await hass.config_entries.async_reload(webhook_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert ( + "Platform withings does not generate unique IDs. ID withings_12345_in_bed already exists - ignoring binary_sensor.henk_in_bed" + not in caplog.text + ) + async def test_polling_binary_sensor( hass: HomeAssistant, @@ -67,7 +77,7 @@ async def test_polling_binary_sensor( entity_id = "binary_sensor.henk_in_bed" - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id) is None with pytest.raises(ClientResponseError): await call_webhook( From a2bc2bf8a0096847de72f07c912720e49e852d26 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 23 Oct 2023 10:21:15 +0300 Subject: [PATCH 719/968] Remove name from Transmission config flow (#102216) * Remove name key from Transmission * Remove name variable completely * remove name error from strings * Change entry title to default name --- .../components/transmission/__init__.py | 2 ++ .../components/transmission/config_flow.py | 8 ++----- .../components/transmission/sensor.py | 13 +----------- .../components/transmission/strings.json | 2 -- .../components/transmission/switch.py | 6 +----- tests/components/transmission/__init__.py | 8 ++++++- .../transmission/test_config_flow.py | 21 ------------------- tests/components/transmission/test_init.py | 4 ++-- 8 files changed, 15 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index be32c95356d..7d019935e6c 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -108,6 +108,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b entity_entry: er.RegistryEntry, ) -> dict[str, Any] | None: """Update unique ID of entity entry.""" + if CONF_NAME not in config_entry.data: + return None match = re.search( f"{config_entry.data[CONF_HOST]}-{config_entry.data[CONF_NAME]} (?P.+)", entity_entry.unique_id, diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d1005f5e84c..fac4e770a26 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import ( CONF_HOST, - CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, @@ -34,7 +33,6 @@ from .errors import AuthenticationError, CannotConnect, UnknownError DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_NAME, default=DEFAULT_NAME): str, vol.Required(CONF_HOST): str, vol.Optional(CONF_USERNAME): str, vol.Optional(CONF_PASSWORD): str, @@ -70,9 +68,6 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): and entry.data[CONF_PORT] == user_input[CONF_PORT] ): return self.async_abort(reason="already_configured") - if entry.data[CONF_NAME] == user_input[CONF_NAME]: - errors[CONF_NAME] = "name_exists" - break try: await get_api(self.hass, user_input) @@ -84,7 +79,8 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not errors: return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input + title=DEFAULT_NAME, + data=user_input, ) return self.async_show_form( diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 0a0f0dae383..2bfa065c19b 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -8,7 +8,7 @@ from transmission_rpc.torrent import Torrent from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME, STATE_IDLE, UnitOfDataRate +from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -35,54 +35,45 @@ async def async_setup_entry( coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - name: str = config_entry.data[CONF_NAME] dev = [ TransmissionSpeedSensor( coordinator, - name, "download_speed", "download", ), TransmissionSpeedSensor( coordinator, - name, "upload_speed", "upload", ), TransmissionStatusSensor( coordinator, - name, "transmission_status", "status", ), TransmissionTorrentsSensor( coordinator, - name, "active_torrents", "active_torrents", ), TransmissionTorrentsSensor( coordinator, - name, "paused_torrents", "paused_torrents", ), TransmissionTorrentsSensor( coordinator, - name, "total_torrents", "total_torrents", ), TransmissionTorrentsSensor( coordinator, - name, "completed_torrents", "completed_torrents", ), TransmissionTorrentsSensor( coordinator, - name, "started_torrents", "started_torrents", ), @@ -102,7 +93,6 @@ class TransmissionSensor( def __init__( self, coordinator: TransmissionDataUpdateCoordinator, - client_name: str, sensor_translation_key: str, key: str, ) -> None: @@ -115,7 +105,6 @@ class TransmissionSensor( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", - name=client_name, ) diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index aaab4d2e2d7..81d94b9aac4 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -4,7 +4,6 @@ "user": { "title": "Set up Transmission Client", "data": { - "name": "[%key:common::config_flow::data::name%]", "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", @@ -20,7 +19,6 @@ } }, "error": { - "name_exists": "Name already exists", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index 3e7573b1951..bf01b5a9cdc 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -5,7 +5,6 @@ from typing import Any from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,11 +26,10 @@ async def async_setup_entry( coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][ config_entry.entry_id ] - name: str = config_entry.data[CONF_NAME] dev = [] for switch_type, switch_name in SWITCH_TYPES.items(): - dev.append(TransmissionSwitch(switch_type, switch_name, coordinator, name)) + dev.append(TransmissionSwitch(switch_type, switch_name, coordinator)) async_add_entities(dev, True) @@ -49,7 +47,6 @@ class TransmissionSwitch( switch_type: str, switch_name: str, coordinator: TransmissionDataUpdateCoordinator, - client_name: str, ) -> None: """Initialize the Transmission switch.""" super().__init__(coordinator) @@ -61,7 +58,6 @@ class TransmissionSwitch( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer="Transmission", - name=client_name, ) @property diff --git a/tests/components/transmission/__init__.py b/tests/components/transmission/__init__.py index 9da6c8304e0..e371a3691a2 100644 --- a/tests/components/transmission/__init__.py +++ b/tests/components/transmission/__init__.py @@ -1,9 +1,15 @@ """Tests for Transmission.""" -MOCK_CONFIG_DATA = { +OLD_MOCK_CONFIG_DATA = { "name": "Transmission", "host": "0.0.0.0", "username": "user", "password": "pass", "port": 9091, } +MOCK_CONFIG_DATA = { + "host": "0.0.0.0", + "username": "user", + "password": "pass", + "port": 9091, +} diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index b4fae8e6f3d..1bfae98fb71 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -71,27 +71,6 @@ async def test_device_already_configured( assert result2["reason"] == "already_configured" -async def test_name_already_configured(hass: HomeAssistant) -> None: - """Test name is already configured.""" - entry = MockConfigEntry( - domain=transmission.DOMAIN, - data=MOCK_CONFIG_DATA, - options={"scan_interval": 120}, - ) - entry.add_to_hass(hass) - - mock_entry = MOCK_CONFIG_DATA.copy() - mock_entry["host"] = "1.1.1.1" - result = await hass.config_entries.flow.async_init( - transmission.DOMAIN, - context={"source": config_entries.SOURCE_USER}, - data=mock_entry, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"name": "name_exists"} - - async def test_options(hass: HomeAssistant) -> None: """Test updating options.""" entry = MockConfigEntry( diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 84bbf6be6ef..63b7ac154ed 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from . import MOCK_CONFIG_DATA +from . import MOCK_CONFIG_DATA, OLD_MOCK_CONFIG_DATA from tests.common import MockConfigEntry @@ -139,7 +139,7 @@ async def test_migrate_unique_id( new_unique_id: str, ) -> None: """Test unique id migration.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA, entry_id="1234") + entry = MockConfigEntry(domain=DOMAIN, data=OLD_MOCK_CONFIG_DATA, entry_id="1234") entry.add_to_hass(hass) entity: er.RegistryEntry = entity_registry.async_get_or_create( From 1176003b51bb5cad2ba78bd67ce0259000f824da Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 23 Oct 2023 10:07:31 +0200 Subject: [PATCH 720/968] Move Ecowitt battery sensor into diagnostic category (#102569) --- homeassistant/components/ecowitt/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 8d5411e9e2e..6d048cc423d 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, UV_INDEX, + EntityCategory, UnitOfElectricPotential, UnitOfIrradiance, UnitOfLength, @@ -94,12 +95,14 @@ ECOWITT_SENSORS_MAPPING: Final = { device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), EcoWittSensorTypes.BATTERY_VOLTAGE: SensorEntityDescription( key="BATTERY_VOLTAGE", device_class=SensorDeviceClass.VOLTAGE, native_unit_of_measurement=UnitOfElectricPotential.VOLT, state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, ), EcoWittSensorTypes.CO2_PPM: SensorEntityDescription( key="CO2_PPM", From 30ba78cf82d2622cd6b9f0bc06ce20c68ce6dd75 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Mon, 23 Oct 2023 01:35:41 -0700 Subject: [PATCH 721/968] Fix resolving Matrix room aliases (#101928) --- homeassistant/components/matrix/__init__.py | 92 ++++++++++++++----- tests/components/matrix/conftest.py | 45 ++++++--- tests/components/matrix/test_matrix_bot.py | 5 +- .../{test_join_rooms.py => test_rooms.py} | 14 ++- tests/components/matrix/test_send_message.py | 18 ++-- 5 files changed, 125 insertions(+), 49 deletions(-) rename tests/components/matrix/{test_join_rooms.py => test_rooms.py} (60%) diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index cf7bcce7b3c..f9ef3593fe6 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Sequence import logging import mimetypes import os @@ -17,6 +18,7 @@ from nio.responses import ( JoinResponse, LoginError, Response, + RoomResolveAliasResponse, UploadError, UploadResponse, WhoamiError, @@ -53,6 +55,9 @@ CONF_COMMANDS = "commands" CONF_WORD = "word" CONF_EXPRESSION = "expression" +CONF_USERNAME_REGEX = "^@[^:]*:.*" +CONF_ROOMS_REGEX = "^[!|#][^:]*:.*" + EVENT_MATRIX_COMMAND = "matrix_command" DEFAULT_CONTENT_TYPE = "application/octet-stream" @@ -65,7 +70,9 @@ ATTR_IMAGES = "images" # optional images WordCommand = NewType("WordCommand", str) ExpressionCommand = NewType("ExpressionCommand", re.Pattern) -RoomID = NewType("RoomID", str) +RoomAlias = NewType("RoomAlias", str) # Starts with "#" +RoomID = NewType("RoomID", str) # Starts with "!" +RoomAnyID = RoomID | RoomAlias class ConfigCommand(TypedDict, total=False): @@ -83,7 +90,9 @@ COMMAND_SCHEMA = vol.All( vol.Exclusive(CONF_WORD, "trigger"): cv.string, vol.Exclusive(CONF_EXPRESSION, "trigger"): cv.is_regex, vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All( + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] + ), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -95,10 +104,10 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_HOMESERVER): cv.url, vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"), + vol.Required(CONF_USERNAME): cv.matches_regex(CONF_USERNAME_REGEX), vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_ROOMS, default=[]): vol.All( - cv.ensure_list, [cv.string] + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] ), vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA], } @@ -116,7 +125,9 @@ SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( ), vol.Optional(ATTR_IMAGES): vol.All(cv.ensure_list, [cv.string]), }, - vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), + vol.Required(ATTR_TARGET): vol.All( + cv.ensure_list, [cv.matches_regex(CONF_ROOMS_REGEX)] + ), } ) @@ -160,7 +171,7 @@ class MatrixBot: verify_ssl: bool, username: str, password: str, - listening_rooms: list[RoomID], + listening_rooms: list[RoomAnyID], commands: list[ConfigCommand], ) -> None: """Set up the client.""" @@ -178,11 +189,10 @@ class MatrixBot: homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls ) - self._listening_rooms = listening_rooms - + self._listening_rooms: dict[RoomAnyID, RoomID] = {} self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} - self._load_commands(commands) + self._unparsed_commands = commands async def stop_client(event: HassEvent) -> None: """Run once when Home Assistant stops.""" @@ -195,6 +205,8 @@ class MatrixBot: """Run once when Home Assistant finished startup.""" self._access_tokens = await self._get_auth_tokens() await self._login() + await self._resolve_room_aliases(listening_rooms) + self._load_commands(commands) await self._join_rooms() # Sync once so that we don't respond to past events. await self._client.sync(timeout=30_000) @@ -211,7 +223,7 @@ class MatrixBot: def _load_commands(self, commands: list[ConfigCommand]) -> None: for command in commands: # Set the command for all listening_rooms, unless otherwise specified. - command.setdefault(CONF_ROOMS, self._listening_rooms) # type: ignore[misc] + command.setdefault(CONF_ROOMS, list(self._listening_rooms.values())) # type: ignore[misc] # COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. if (word_command := command.get(CONF_WORD)) is not None: @@ -262,24 +274,60 @@ class MatrixBot: } self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - async def _join_room(self, room_id_or_alias: str) -> None: + async def _resolve_room_alias( + self, room_alias_or_id: RoomAnyID + ) -> dict[RoomAnyID, RoomID]: + """Resolve a single RoomAlias if needed.""" + if room_alias_or_id.startswith("!"): + room_id = RoomID(room_alias_or_id) + _LOGGER.debug("Will listen to room_id '%s'", room_id) + elif room_alias_or_id.startswith("#"): + room_alias = RoomAlias(room_alias_or_id) + resolve_response = await self._client.room_resolve_alias(room_alias) + if isinstance(resolve_response, RoomResolveAliasResponse): + room_id = RoomID(resolve_response.room_id) + _LOGGER.debug( + "Will listen to room_alias '%s' as room_id '%s'", + room_alias_or_id, + room_id, + ) + else: + _LOGGER.error( + "Could not resolve '%s' to a room_id: '%s'", + room_alias_or_id, + resolve_response, + ) + return {} + # The config schema guarantees it's a valid room alias or id, so room_id is always set. + return {room_alias_or_id: room_id} + + async def _resolve_room_aliases(self, listening_rooms: list[RoomAnyID]) -> None: + """Resolve any RoomAliases into RoomIDs for the purpose of client interactions.""" + resolved_rooms = [ + self.hass.async_create_task(self._resolve_room_alias(room_alias_or_id)) + for room_alias_or_id in listening_rooms + ] + for resolved_room in asyncio.as_completed(resolved_rooms): + self._listening_rooms |= await resolved_room + + async def _join_room(self, room_id: RoomID, room_alias_or_id: RoomAnyID) -> None: """Join a room or do nothing if already joined.""" - join_response = await self._client.join(room_id_or_alias) + join_response = await self._client.join(room_id) if isinstance(join_response, JoinResponse): - _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + _LOGGER.debug("Joined or already in room '%s'", room_alias_or_id) elif isinstance(join_response, JoinError): _LOGGER.error( "Could not join room '%s': %s", - room_id_or_alias, + room_alias_or_id, join_response, ) async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" rooms = [ - self.hass.async_create_task(self._join_room(room_id)) - for room_id in self._listening_rooms + self.hass.async_create_task(self._join_room(room_id, room_alias_or_id)) + for room_alias_or_id, room_id in self._listening_rooms.items() ] await asyncio.wait(rooms) @@ -356,11 +404,11 @@ class MatrixBot: await self._store_auth_token(self._client.access_token) async def _handle_room_send( - self, target_room: RoomID, message_type: str, content: dict + self, target_room: RoomAnyID, message_type: str, content: dict ) -> None: """Wrap _client.room_send and handle ErrorResponses.""" response: Response = await self._client.room_send( - room_id=target_room, + room_id=self._listening_rooms.get(target_room, target_room), message_type=message_type, content=content, ) @@ -374,7 +422,7 @@ class MatrixBot: _LOGGER.debug("Message delivered to room '%s'", target_room) async def _handle_multi_room_send( - self, target_rooms: list[RoomID], message_type: str, content: dict + self, target_rooms: Sequence[RoomAnyID], message_type: str, content: dict ) -> None: """Wrap _handle_room_send for multiple target_rooms.""" _tasks = [] @@ -390,7 +438,9 @@ class MatrixBot: ) await asyncio.wait(_tasks) - async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> None: + async def _send_image( + self, image_path: str, target_rooms: Sequence[RoomAnyID] + ) -> None: """Upload an image, then send it to all target_rooms.""" _is_allowed_path = await self.hass.async_add_executor_job( self.hass.config.is_allowed_path, image_path @@ -442,7 +492,7 @@ class MatrixBot: ) async def _send_message( - self, message: str, target_rooms: list[RoomID], data: dict | None + self, message: str, target_rooms: list[RoomAnyID], data: dict | None ) -> None: """Send a message to the Matrix server.""" content = {"msgtype": "m.text", "body": message} diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py index d0970b96019..1198d7e6012 100644 --- a/tests/components/matrix/conftest.py +++ b/tests/components/matrix/conftest.py @@ -14,6 +14,8 @@ from nio import ( LoginError, LoginResponse, Response, + RoomResolveAliasError, + RoomResolveAliasResponse, UploadResponse, WhoamiError, WhoamiResponse, @@ -48,8 +50,15 @@ from tests.common import async_capture_events TEST_NOTIFIER_NAME = "matrix_notify" +TEST_HOMESERVER = "example.com" TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" -TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_ROOM_A_ID = "!RoomA-ID:example.com" +TEST_ROOM_B_ID = "!RoomB-ID:example.com" +TEST_ROOM_B_ALIAS = "#RoomB-Alias:example.com" +TEST_JOINABLE_ROOMS = { + TEST_ROOM_A_ID: TEST_ROOM_A_ID, + TEST_ROOM_B_ALIAS: TEST_ROOM_B_ID, +} TEST_BAD_ROOM = "!UninvitedRoom:example.com" TEST_MXID = "@user:example.com" TEST_DEVICE_ID = "FAKEID" @@ -65,8 +74,16 @@ class _MockAsyncClient(AsyncClient): async def close(self): return None + async def room_resolve_alias(self, room_alias: str): + if room_id := TEST_JOINABLE_ROOMS.get(room_alias): + return RoomResolveAliasResponse( + room_alias=room_alias, room_id=room_id, servers=[TEST_HOMESERVER] + ) + else: + return RoomResolveAliasError(message=f"Could not resolve {room_alias}") + async def join(self, room_id: RoomID): - if room_id in TEST_JOINABLE_ROOMS: + if room_id in TEST_JOINABLE_ROOMS.values(): return JoinResponse(room_id=room_id) else: return JoinError(message="Not allowed to join this room.") @@ -102,10 +119,10 @@ class _MockAsyncClient(AsyncClient): async def room_send(self, *args, **kwargs): if not self.logged_in: raise LocalProtocolError - if kwargs["room_id"] in TEST_JOINABLE_ROOMS: - return Response() - else: + if kwargs["room_id"] not in TEST_JOINABLE_ROOMS.values(): return ErrorResponse(message="Cannot send a message in this room.") + else: + return Response() async def sync(self, *args, **kwargs): return None @@ -123,7 +140,7 @@ MOCK_CONFIG_DATA = { CONF_USERNAME: TEST_MXID, CONF_PASSWORD: TEST_PASSWORD, CONF_VERIFY_SSL: True, - CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_ROOMS: list(TEST_JOINABLE_ROOMS), CONF_COMMANDS: [ { CONF_WORD: "WordTrigger", @@ -143,35 +160,35 @@ MOCK_CONFIG_DATA = { } MOCK_WORD_COMMANDS = { - "!RoomIdString:example.com": { + TEST_ROOM_A_ID: { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } }, - "#RoomAliasString:example.com": { + TEST_ROOM_B_ID: { "WordTrigger": { "word": "WordTrigger", "name": "WordTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } }, } MOCK_EXPRESSION_COMMANDS = { - "!RoomIdString:example.com": [ + TEST_ROOM_A_ID: [ { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } ], - "#RoomAliasString:example.com": [ + TEST_ROOM_B_ID: [ { "expression": re.compile("My name is (?P.*)"), "name": "ExpressionTriggerEventName", - "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + "rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID], } ], } diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py index 0b150a629fe..0048f6665e8 100644 --- a/tests/components/matrix/test_matrix_bot.py +++ b/tests/components/matrix/test_matrix_bot.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from .conftest import ( MOCK_EXPRESSION_COMMANDS, MOCK_WORD_COMMANDS, - TEST_JOINABLE_ROOMS, TEST_NOTIFIER_NAME, + TEST_ROOM_A_ID, ) @@ -34,12 +34,13 @@ async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): async def test_commands(hass, matrix_bot: MatrixBot, command_events): """Test that the configured commands were parsed correctly.""" + await hass.async_start() assert len(command_events) == 0 assert matrix_bot._word_commands == MOCK_WORD_COMMANDS assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS - room_id = TEST_JOINABLE_ROOMS[0] + room_id = TEST_ROOM_A_ID room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) # Test single-word command. diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_rooms.py similarity index 60% rename from tests/components/matrix/test_join_rooms.py rename to tests/components/matrix/test_rooms.py index 54856b91ac3..29081b80fd5 100644 --- a/tests/components/matrix/test_join_rooms.py +++ b/tests/components/matrix/test_rooms.py @@ -5,18 +5,24 @@ from homeassistant.components.matrix import MatrixBot from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS -async def test_join(matrix_bot: MatrixBot, caplog): +async def test_join(hass, matrix_bot: MatrixBot, caplog): """Test joining configured rooms.""" - # Join configured rooms. - await matrix_bot._join_rooms() + await hass.async_start() for room_id in TEST_JOINABLE_ROOMS: assert f"Joined or already in room '{room_id}'" in caplog.messages # Joining a disallowed room should not raise an exception. - matrix_bot._listening_rooms = [TEST_BAD_ROOM] + matrix_bot._listening_rooms = {TEST_BAD_ROOM: TEST_BAD_ROOM} await matrix_bot._join_rooms() assert ( f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." in caplog.messages ) + + +async def test_resolve_aliases(hass, matrix_bot: MatrixBot): + """Test resolving configured room aliases into room ids.""" + + await hass.async_start() + assert matrix_bot._listening_rooms == TEST_JOINABLE_ROOMS diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py index 34964f2b091..47c3e08aa48 100644 --- a/tests/components/matrix/test_send_message.py +++ b/tests/components/matrix/test_send_message.py @@ -17,30 +17,32 @@ async def test_send_message( hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog ): """Test the send_message service.""" + + await hass.async_start() assert len(matrix_events) == 0 await matrix_bot._login() # Send a message without an attached image. - data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_JOINABLE_ROOMS} + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: list(TEST_JOINABLE_ROOMS)} await hass.services.async_call( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages # Send an HTML message without an attached image. data = { ATTR_MESSAGE: "Test message", - ATTR_TARGET: TEST_JOINABLE_ROOMS, + ATTR_TARGET: list(TEST_JOINABLE_ROOMS), ATTR_DATA: {ATTR_FORMAT: FORMAT_HTML}, } await hass.services.async_call( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages # Send a message with an attached image. data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} @@ -48,8 +50,8 @@ async def test_send_message( MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True ) - for room_id in TEST_JOINABLE_ROOMS: - assert f"Message delivered to room '{room_id}'" in caplog.messages + for room_alias_or_id in TEST_JOINABLE_ROOMS: + assert f"Message delivered to room '{room_alias_or_id}'" in caplog.messages async def test_unsendable_message( From a6ade591336e10f1fdabe5ad2ec8504f6497182e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Oct 2023 10:39:17 +0200 Subject: [PATCH 722/968] Make Withings sleep sensor only show last night (#101993) --- .../components/withings/coordinator.py | 5 ++-- .../withings/snapshots/test_sensor.ambr | 28 +++++++++---------- tests/components/withings/test_sensor.py | 21 ++++++++++++++ 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 3b39dddb27e..7964a755b4d 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -15,7 +15,6 @@ from aiowithings import ( WithingsUnauthorizedError, aggregate_measurements, ) -from aiowithings.helpers import aggregate_sleep_summary from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -145,7 +144,9 @@ class WithingsSleepDataUpdateCoordinator( SleepSummaryDataFields.TOTAL_TIME_AWAKE, ], ) - return aggregate_sleep_summary(response) + if not response: + return None + return response[0] class WithingsBedPresenceDataUpdateCoordinator(WithingsDataUpdateCoordinator[None]): diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index cf8ff0a462b..75d87a23a9c 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -42,7 +42,7 @@ 'entity_id': 'sensor.henk_average_heart_rate', 'last_changed': , 'last_updated': , - 'state': '83', + 'state': '103', }) # --- # name: test_all_entities[sensor.henk_average_respiratory_rate] @@ -100,7 +100,7 @@ 'entity_id': 'sensor.henk_breathing_disturbances_intensity', 'last_changed': , 'last_updated': , - 'state': '10', + 'state': '9', }) # --- # name: test_all_entities[sensor.henk_deep_sleep] @@ -116,7 +116,7 @@ 'entity_id': 'sensor.henk_deep_sleep', 'last_changed': , 'last_updated': , - 'state': '26220', + 'state': '5820', }) # --- # name: test_all_entities[sensor.henk_diastolic_blood_pressure] @@ -315,7 +315,7 @@ 'entity_id': 'sensor.henk_light_sleep', 'last_changed': , 'last_updated': , - 'state': '58440', + 'state': '10440', }) # --- # name: test_all_entities[sensor.henk_maximum_heart_rate] @@ -330,7 +330,7 @@ 'entity_id': 'sensor.henk_maximum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '108', + 'state': '120', }) # --- # name: test_all_entities[sensor.henk_maximum_respiratory_rate] @@ -359,7 +359,7 @@ 'entity_id': 'sensor.henk_minimum_heart_rate', 'last_changed': , 'last_updated': , - 'state': '58', + 'state': '70', }) # --- # name: test_all_entities[sensor.henk_minimum_respiratory_rate] @@ -435,7 +435,7 @@ 'entity_id': 'sensor.henk_rem_sleep', 'last_changed': , 'last_updated': , - 'state': '17280', + 'state': '2400', }) # --- # name: test_all_entities[sensor.henk_skin_temperature] @@ -481,7 +481,7 @@ 'entity_id': 'sensor.henk_sleep_score', 'last_changed': , 'last_updated': , - 'state': '90', + 'state': '37', }) # --- # name: test_all_entities[sensor.henk_snoring] @@ -494,7 +494,7 @@ 'entity_id': 'sensor.henk_snoring', 'last_changed': , 'last_updated': , - 'state': '1044', + 'state': '1080', }) # --- # name: test_all_entities[sensor.henk_snoring_episode_count] @@ -507,7 +507,7 @@ 'entity_id': 'sensor.henk_snoring_episode_count', 'last_changed': , 'last_updated': , - 'state': '87', + 'state': '18', }) # --- # name: test_all_entities[sensor.henk_soft_activity_today] @@ -613,7 +613,7 @@ 'entity_id': 'sensor.henk_time_to_sleep', 'last_changed': , 'last_updated': , - 'state': '780', + 'state': '540', }) # --- # name: test_all_entities[sensor.henk_time_to_wakeup] @@ -629,7 +629,7 @@ 'entity_id': 'sensor.henk_time_to_wakeup', 'last_changed': , 'last_updated': , - 'state': '996', + 'state': '1140', }) # --- # name: test_all_entities[sensor.henk_total_calories_burnt_today] @@ -685,7 +685,7 @@ 'entity_id': 'sensor.henk_wakeup_count', 'last_changed': , 'last_updated': , - 'state': '8', + 'state': '1', }) # --- # name: test_all_entities[sensor.henk_wakeup_time] @@ -701,7 +701,7 @@ 'entity_id': 'sensor.henk_wakeup_time', 'last_changed': , 'last_updated': , - 'state': '3468', + 'state': '3060', }) # --- # name: test_all_entities[sensor.henk_weight] diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 1acfc324d81..1a405dd4844 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -243,3 +243,24 @@ async def test_activity_sensors_created_when_receive_activity_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_steps_today") is not None + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_no_sleep( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test no sleep found.""" + await setup_integration(hass, polling_config_entry, False) + + withings.get_sleep_summary_since.return_value = [] + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("sensor.henk_average_respiratory_rate") + assert state is not None + assert state.state == STATE_UNAVAILABLE From e4af09d26173c5c9c232d1e3e2125d31788fde69 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Oct 2023 10:48:05 +0200 Subject: [PATCH 723/968] Update base image to 2023.10.1 (#102568) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index e931d193a58..813676de3a7 100644 --- a/build.yaml +++ b/build.yaml @@ -1,10 +1,10 @@ image: ghcr.io/home-assistant/{arch}-homeassistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.10.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.10.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.10.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.10.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.10.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From 0c5b96384700db5a92ac50ca1af174b343dcdf75 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 23 Oct 2023 10:57:19 +0200 Subject: [PATCH 724/968] Add lokalise multi reference check to hassfest (#101876) --- script/hassfest/translations.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 22c3e927703..5c6d7b19719 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -532,6 +532,11 @@ def validate_translation_file( # noqa: C901 "translations", f"{reference['source']} contains invalid reference {reference['ref']}: Could not find {key}", ) + elif match := re.match(RE_REFERENCE, search[key]): + integration.add_error( + "translations", + f"Lokalise supports only one level of references: \"{reference['source']}\" should point to directly to \"{match.groups()[0]}\"", + ) def validate(integrations: dict[str, Integration], config: Config) -> None: From 109819e9cd1298fc31e80213a2b1846ce7ebf02b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 23 Oct 2023 10:58:11 +0200 Subject: [PATCH 725/968] Only allow a single duotecno config entry (#102478) --- .../components/duotecno/config_flow.py | 3 +++ .../components/duotecno/strings.json | 3 ++- tests/components/duotecno/test_config_flow.py | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/duotecno/config_flow.py b/homeassistant/components/duotecno/config_flow.py index 37087d4ea1a..6f08b025835 100644 --- a/homeassistant/components/duotecno/config_flow.py +++ b/homeassistant/components/duotecno/config_flow.py @@ -34,6 +34,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + errors: dict[str, str] = {} if user_input is not None: try: diff --git a/homeassistant/components/duotecno/strings.json b/homeassistant/components/duotecno/strings.json index a00647993a8..93a545d31dc 100644 --- a/homeassistant/components/duotecno/strings.json +++ b/homeassistant/components/duotecno/strings.json @@ -12,7 +12,8 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" } }, "entity": { diff --git a/tests/components/duotecno/test_config_flow.py b/tests/components/duotecno/test_config_flow.py index a2dc265ae6e..a02fea8008c 100644 --- a/tests/components/duotecno/test_config_flow.py +++ b/tests/components/duotecno/test_config_flow.py @@ -9,6 +9,8 @@ from homeassistant.components.duotecno.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from tests.common import MockConfigEntry + pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -87,3 +89,20 @@ async def test_invalid(hass: HomeAssistant, test_side_effect, test_error): "port": 1234, "password": "test-password2", } + + +async def test_already_setup(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test duoteco flow - already setup.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="duotecno_1234", + data={}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" From 4c99d2607ffc3beec7d44558afdb60f606fb255c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Oct 2023 11:04:21 +0200 Subject: [PATCH 726/968] Fix fibaro tests (#102575) --- tests/components/fibaro/conftest.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index e15d6509a00..d86817814b1 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -16,6 +16,7 @@ TEST_URL = "http://192.168.1.1/api/" TEST_USERNAME = "user" TEST_PASSWORD = "password" TEST_VERSION = "4.360" +TEST_MODEL = "HC3" @pytest.fixture @@ -70,6 +71,7 @@ def mock_fibaro_client() -> Generator[Mock, None, None]: info_mock.serial_number = TEST_SERIALNUMBER info_mock.hc_name = TEST_NAME info_mock.current_version = TEST_VERSION + info_mock.platform = TEST_MODEL with patch( "homeassistant.components.fibaro.FibaroClient", autospec=True From 3e23a4b4eeb7cd08e15bc541dd892053764d131f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 23 Oct 2023 11:11:06 +0200 Subject: [PATCH 727/968] Bump github/codeql-action from 2.22.3 to 2.22.4 (#102566) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/codeql.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 3d5b0cf8e57..da7021e9df3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -29,11 +29,11 @@ jobs: uses: actions/checkout@v4.1.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2.22.3 + uses: github/codeql-action/init@v2.22.4 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2.22.3 + uses: github/codeql-action/analyze@v2.22.4 with: category: "/language:python" From e27baedf32a72c9db960cead295ccfb5dffb0f59 Mon Sep 17 00:00:00 2001 From: TopdRob Date: Mon, 23 Oct 2023 11:21:33 +0200 Subject: [PATCH 728/968] Bump adax to 0.3.0 (#102556) --- homeassistant/components/adax/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adax/manifest.json b/homeassistant/components/adax/manifest.json index a8d61746292..65cffc509d5 100644 --- a/homeassistant/components/adax/manifest.json +++ b/homeassistant/components/adax/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/adax", "iot_class": "local_polling", "loggers": ["adax", "adax_local"], - "requirements": ["adax==0.2.0", "Adax-local==0.1.5"] + "requirements": ["adax==0.3.0", "Adax-local==0.1.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5ee9d20b508..90d2b6eda97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -150,7 +150,7 @@ WSDiscovery==2.0.0 accuweather==1.0.0 # homeassistant.components.adax -adax==0.2.0 +adax==0.3.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3aa18738c4f..629b7d573ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -131,7 +131,7 @@ WSDiscovery==2.0.0 accuweather==1.0.0 # homeassistant.components.adax -adax==0.2.0 +adax==0.3.0 # homeassistant.components.androidtv adb-shell[async]==0.4.4 From 8a7de27946f3ca62f999cda69fc42744640fd3b8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Oct 2023 11:55:12 +0200 Subject: [PATCH 729/968] Try negative WAQI station number before aborting (#102550) --- homeassistant/components/waqi/config_flow.py | 34 ++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py index d23afdf33ee..55740913487 100644 --- a/homeassistant/components/waqi/config_flow.py +++ b/homeassistant/components/waqi/config_flow.py @@ -39,6 +39,22 @@ _LOGGER = logging.getLogger(__name__) CONF_MAP = "map" +async def get_by_station_number( + client: WAQIClient, station_number: int +) -> tuple[WAQIAirQuality | None, dict[str, str]]: + """Get measuring station by station number.""" + errors: dict[str, str] = {} + measuring_station: WAQIAirQuality | None = None + try: + measuring_station = await client.get_by_station_number(station_number) + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _LOGGER.exception(exc) + errors["base"] = "unknown" + return measuring_station, errors + + class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for World Air Quality Index (WAQI).""" @@ -141,16 +157,16 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): session=async_get_clientsession(self.hass) ) as waqi_client: waqi_client.authenticate(self.data[CONF_API_KEY]) - try: - measuring_station = await waqi_client.get_by_station_number( - user_input[CONF_STATION_NUMBER] + station_number = user_input[CONF_STATION_NUMBER] + measuring_station, errors = await get_by_station_number( + waqi_client, abs(station_number) + ) + if not measuring_station: + measuring_station, _ = await get_by_station_number( + waqi_client, + abs(station_number) - station_number - station_number, ) - except WAQIConnectionError: - errors["base"] = "cannot_connect" - except Exception as exc: # pylint: disable=broad-except - _LOGGER.exception(exc) - errors["base"] = "unknown" - else: + if measuring_station: return await self._async_create_entry(measuring_station) return self.async_show_form( step_id=CONF_STATION_NUMBER, From 8c9c915c45bc59574a50ebf9db09c427e92f612e Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Mon, 23 Oct 2023 12:14:10 +0200 Subject: [PATCH 730/968] Bump code-quality to silver for duotecno (#102284) Co-authored-by: Franck Nijhof --- homeassistant/components/duotecno/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index 96f76517a92..f6482791292 100644 --- a/homeassistant/components/duotecno/manifest.json +++ b/homeassistant/components/duotecno/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/duotecno", "iot_class": "local_push", + "quality_scale": "silver", "requirements": ["pyDuotecno==2023.10.1"] } From d5af6c595d989c229065a05c5f9178c21e8c7292 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Oct 2023 12:34:32 +0200 Subject: [PATCH 731/968] Fix runaway regex in translations.develop (#102386) Co-authored-by: Franck Nijhof --- script/translations/develop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/translations/develop.py b/script/translations/develop.py index 3bfaa279e93..3e386afb641 100644 --- a/script/translations/develop.py +++ b/script/translations/develop.py @@ -69,7 +69,7 @@ def substitute_translation_references(integration_strings, flattened_translation def substitute_reference(value, flattened_translations): """Substitute localization key references in a translation string.""" - matches = re.findall(r"\[\%key:((?:[a-z0-9-_]+|[:]{2})*)\%\]", value) + matches = re.findall(r"\[\%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+)\%\]", value) if not matches: return value From 42c062de68bed451dd007cb4431dea2f7a260f4d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 23 Oct 2023 12:59:13 +0200 Subject: [PATCH 732/968] Only add Withings sleep sensors when we have data (#102578) Co-authored-by: Robert Resch --- homeassistant/components/withings/sensor.py | 37 +++++++++---- tests/components/withings/__init__.py | 10 +++- tests/components/withings/conftest.py | 10 ++-- .../components/withings/test_binary_sensor.py | 4 +- tests/components/withings/test_sensor.py | 53 ++++++++++++++----- 5 files changed, 83 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 0d841c4bb2c..a531bf49986 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -263,7 +263,6 @@ SLEEP_SENSORS = [ icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, state_class=SensorStateClass.MEASUREMENT, - entity_registry_enabled_default=False, ), WithingsSleepSensorEntityDescription( key="sleep_tosleep_duration_seconds", @@ -645,9 +644,33 @@ async def async_setup_entry( sleep_coordinator = withings_data.sleep_coordinator - entities.extend( - WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS + sleep_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, + DOMAIN, + f"withings_{entry.unique_id}_sleep_deep_duration_seconds", ) + + if sleep_coordinator.data is not None or sleep_entities_setup_before: + entities.extend( + WithingsSleepSensor(sleep_coordinator, attribute) + for attribute in SLEEP_SENSORS + ) + else: + remove_listener: Callable[[], None] + + def _async_add_sleep_entities() -> None: + """Add sleep entities.""" + if sleep_coordinator.data is not None: + async_add_entities( + WithingsSleepSensor(sleep_coordinator, attribute) + for attribute in SLEEP_SENSORS + ) + remove_listener() + + remove_listener = sleep_coordinator.async_add_listener( + _async_add_sleep_entities + ) + async_add_entities(entities) @@ -695,14 +718,10 @@ class WithingsSleepSensor(WithingsSensor): @property def native_value(self) -> StateType: """Return the state of the entity.""" - assert self.coordinator.data + if not self.coordinator.data: + return None return self.entity_description.value_fn(self.coordinator.data) - @property - def available(self) -> bool: - """Return if the sensor is available.""" - return super().available and self.coordinator.data is not None - class WithingsGoalsSensor(WithingsSensor): """Implementation of a Withings goals sensor.""" diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 56bee0c30db..8d8207cdf9a 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Activity, Goals, MeasurementGroup +from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -92,3 +92,11 @@ def load_activity_fixture( """Return measurement from fixture.""" activity_json = load_json_array_fixture(fixture) return [Activity.from_api(activity) for activity in activity_json] + + +def load_sleep_fixture( + fixture: str = "withings/sleep_summaries.json", +) -> list[SleepSummary]: + """Return sleep summaries from fixture.""" + sleep_json = load_json_array_fixture("withings/sleep_summaries.json") + return [SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json] diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 066a9eed031..b040ccd2b58 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -3,7 +3,7 @@ from datetime import timedelta import time from unittest.mock import AsyncMock, patch -from aiowithings import Device, SleepSummary, WithingsClient +from aiowithings import Device, WithingsClient from aiowithings.models import NotificationConfiguration import pytest @@ -20,6 +20,7 @@ from tests.components.withings import ( load_activity_fixture, load_goals_fixture, load_measurements_fixture, + load_sleep_fixture, ) CLIENT_ID = "1234" @@ -138,11 +139,6 @@ def mock_withings(): measurement_groups = load_measurements_fixture() - sleep_json = load_json_array_fixture("withings/sleep_summaries.json") - sleep_summaries = [ - SleepSummary.from_api(sleep_summary) for sleep_summary in sleep_json - ] - notification_json = load_json_array_fixture("withings/notifications.json") notifications = [ NotificationConfiguration.from_api(not_conf) for not_conf in notification_json @@ -155,7 +151,7 @@ def mock_withings(): mock.get_goals.return_value = load_goals_fixture() mock.get_measurement_in_period.return_value = measurement_groups mock.get_measurement_since.return_value = measurement_groups - mock.get_sleep_summary_since.return_value = sleep_summaries + mock.get_sleep_summary_since.return_value = load_sleep_fixture() mock.get_activities_since.return_value = activities mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index c56b14ae893..c93c4522684 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -59,8 +59,8 @@ async def test_binary_sensor( assert hass.states.get(entity_id).state == STATE_UNKNOWN assert ( - "Platform withings does not generate unique IDs. ID withings_12345_in_bed already exists - ignoring binary_sensor.henk_in_bed" - not in caplog.text + "Platform withings does not generate unique IDs. ID withings_12345_in_bed " + "already exists - ignoring binary_sensor.henk_in_bed" not in caplog.text ) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 1a405dd4844..d7add6905e5 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -14,6 +14,7 @@ from . import ( load_activity_fixture, load_goals_fixture, load_measurements_fixture, + load_sleep_fixture, setup_integration, ) @@ -127,7 +128,7 @@ async def test_update_new_measurement_creates_new_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("sensor.henk_fat_mass") is not None + assert hass.states.get("sensor.henk_fat_mass") async def test_update_new_goals_creates_new_sensor( @@ -143,7 +144,7 @@ async def test_update_new_goals_creates_new_sensor( await setup_integration(hass, polling_config_entry, False) assert hass.states.get("sensor.henk_step_goal") is None - assert hass.states.get("sensor.henk_weight_goal") is not None + assert hass.states.get("sensor.henk_weight_goal") withings.get_goals.return_value = load_goals_fixture() @@ -151,7 +152,7 @@ async def test_update_new_goals_creates_new_sensor( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("sensor.henk_step_goal") is not None + assert hass.states.get("sensor.henk_step_goal") async def test_activity_sensors_unknown_next_day( @@ -164,7 +165,7 @@ async def test_activity_sensors_unknown_next_day( freezer.move_to("2023-10-21") await setup_integration(hass, polling_config_entry, False) - assert hass.states.get("sensor.henk_steps_today") is not None + assert hass.states.get("sensor.henk_steps_today") withings.get_activities_since.return_value = [] @@ -206,7 +207,7 @@ async def test_activity_sensors_created_when_existed( freezer.move_to("2023-10-21") await setup_integration(hass, polling_config_entry, False) - assert hass.states.get("sensor.henk_steps_today") is not None + assert hass.states.get("sensor.henk_steps_today") assert hass.states.get("sensor.henk_steps_today").state != STATE_UNKNOWN withings.get_activities_in_period.return_value = [] @@ -242,25 +243,53 @@ async def test_activity_sensors_created_when_receive_activity_data( async_fire_time_changed(hass) await hass.async_block_till_done() - assert hass.states.get("sensor.henk_steps_today") is not None + assert hass.states.get("sensor.henk_steps_today") @pytest.mark.usefixtures("entity_registry_enabled_by_default") -async def test_no_sleep( +async def test_sleep_sensors_created_when_existed( hass: HomeAssistant, - snapshot: SnapshotAssertion, withings: AsyncMock, polling_config_entry: MockConfigEntry, freezer: FrozenDateTimeFactory, ) -> None: - """Test no sleep found.""" + """Test sleep sensors will be added if they existed before.""" await setup_integration(hass, polling_config_entry, False) + assert hass.states.get("sensor.henk_deep_sleep") + assert hass.states.get("sensor.henk_deep_sleep").state != STATE_UNKNOWN + withings.get_sleep_summary_since.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_deep_sleep").state == STATE_UNKNOWN + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_sleep_sensors_created_when_receive_sleep_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test sleep sensors will be added if we receive sleep data.""" + withings.get_sleep_summary_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_deep_sleep") is None + freezer.tick(timedelta(minutes=10)) async_fire_time_changed(hass) await hass.async_block_till_done() - state = hass.states.get("sensor.henk_average_respiratory_rate") - assert state is not None - assert state.state == STATE_UNAVAILABLE + assert hass.states.get("sensor.henk_deep_sleep") is None + + withings.get_sleep_summary_since.return_value = load_sleep_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_deep_sleep") From 9c0427a7acdf2e603eaab692250399b34be77fc2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 23 Oct 2023 13:08:47 +0200 Subject: [PATCH 733/968] Update pylint to 3.0.2 (#102576) --- requirements_test.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5805ba4d03e..69f8936b18b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==3.0.0 +astroid==3.0.1 coverage==7.3.2 freezegun==1.2.2 mock-open==1.4.0 mypy==1.6.1 pre-commit==3.5.0 pydantic==1.10.12 -pylint==3.0.1 +pylint==3.0.2 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 From 04c0bca487550d91ea8d8b8430d5a1c3b4b9ee90 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Mon, 23 Oct 2023 14:00:47 +0200 Subject: [PATCH 734/968] Remove name from device info in devolo Home Network (#102585) --- .../components/devolo_home_network/entity.py | 1 - .../snapshots/test_init.ambr | 29 +++++++++++++++++++ .../devolo_home_network/test_init.py | 24 ++++++++++----- 3 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 tests/components/devolo_home_network/snapshots/test_init.ambr diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index ff0b2ba2c48..53c502dc811 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -52,7 +52,6 @@ class DevoloEntity(Entity): identifiers={(DOMAIN, str(device.serial_number))}, manufacturer="devolo", model=device.product, - name=entry.title, serial_number=device.serial_number, sw_version=device.firmware_version, ) diff --git a/tests/components/devolo_home_network/snapshots/test_init.ambr b/tests/components/devolo_home_network/snapshots/test_init.ambr new file mode 100644 index 00000000000..f2c27183945 --- /dev/null +++ b/tests/components/devolo_home_network/snapshots/test_init.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_setup_entry + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': 'http://192.0.2.1', + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'devolo_home_network', + '1234567890', + ), + }), + 'is_new': False, + 'manufacturer': 'devolo', + 'model': 'dLAN pro 1200+ WiFi ac', + 'name': 'Mock Title', + 'name_by_user': None, + 'serial_number': '1234567890', + 'suggested_area': None, + 'sw_version': '5.6.1', + 'via_device_id': None, + }) +# --- diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 3c207a1aaef..e34af0dcbaf 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR from homeassistant.components.button import DOMAIN as BUTTON @@ -15,6 +16,7 @@ from homeassistant.components.update import DOMAIN as UPDATE from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity_platform import async_get_platforms from . import configure_integration @@ -24,16 +26,22 @@ from .mock import MockDevice from tests.common import MockConfigEntry -@pytest.mark.usefixtures("mock_device") -async def test_setup_entry(hass: HomeAssistant) -> None: +async def test_setup_entry( + hass: HomeAssistant, + mock_device: MockDevice, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: """Test setup entry.""" entry = configure_integration(hass) - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ), patch("homeassistant.core.EventBus.async_listen_once"): - assert await hass.config_entries.async_setup(entry.entry_id) - assert entry.state is ConfigEntryState.LOADED + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.LOADED + + device_info = device_registry.async_get_device( + {(DOMAIN, mock_device.serial_number)} + ) + assert device_info == snapshot @pytest.mark.usefixtures("mock_device") From 5b39a08feb0b63b76c2002d5561102b04619b7e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Oct 2023 14:47:43 +0200 Subject: [PATCH 735/968] Update adguardhome to 0.6.2 (#102582) Update adguard to 0.6.2 --- homeassistant/components/adguard/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json index 36973ae96ab..24e1283e9df 100644 --- a/homeassistant/components/adguard/manifest.json +++ b/homeassistant/components/adguard/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["adguardhome"], - "requirements": ["adguardhome==0.6.1"] + "requirements": ["adguardhome==0.6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 90d2b6eda97..91e506a487f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -159,7 +159,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.1 +adguardhome==0.6.2 # homeassistant.components.advantage_air advantage-air==0.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 629b7d573ca..06f4e7260fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -140,7 +140,7 @@ adb-shell[async]==0.4.4 adext==0.4.2 # homeassistant.components.adguard -adguardhome==0.6.1 +adguardhome==0.6.2 # homeassistant.components.advantage_air advantage-air==0.4.4 From 40ccae3d07032e389d480df039950edb042df4b8 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 23 Oct 2023 09:34:28 -0400 Subject: [PATCH 736/968] Add coordinator to Blink (#102536) --- homeassistant/components/blink/__init__.py | 58 ++++++-------- .../components/blink/alarm_control_panel.py | 79 ++++++++++--------- .../components/blink/binary_sensor.py | 36 ++++++--- homeassistant/components/blink/camera.py | 34 +++++--- homeassistant/components/blink/const.py | 1 - homeassistant/components/blink/coordinator.py | 33 ++++++++ homeassistant/components/blink/sensor.py | 45 +++++++---- 7 files changed, 173 insertions(+), 113 deletions(-) create mode 100644 homeassistant/components/blink/coordinator.py diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index e57b8e52729..89438c9c7c1 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -31,6 +31,7 @@ from .const import ( SERVICE_SAVE_VIDEO, SERVICE_SEND_PIN, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -84,6 +85,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: auth_data = deepcopy(dict(entry.data)) blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + coordinator = BlinkUpdateCoordinator(hass, blink) + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -94,18 +98,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Attempting a reauth flow") raise ConfigEntryAuthFailed("Need 2FA for Blink") - hass.data[DOMAIN][entry.entry_id] = blink - if not blink.available: raise ConfigEntryNotReady await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) - await blink.refresh(force=True) async def blink_refresh(event_time=None): """Call blink to refresh info.""" - await hass.data[DOMAIN][entry.entry_id].refresh(force_cache=True) + await coordinator.api.refresh(force_cache=True) async def async_save_video(call): """Call save video service handler.""" @@ -118,8 +119,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def send_pin(call): """Call blink to send new pin.""" pin = call.data[CONF_PIN] - await hass.data[DOMAIN][entry.entry_id].auth.send_auth_key( - hass.data[DOMAIN][entry.entry_id], + await coordinator.api.auth.send_auth_key( + hass.data[DOMAIN][entry.entry_id].api, pin, ) @@ -154,26 +155,21 @@ def _async_import_options_from_data_if_missing( async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Blink entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + return True - if not unload_ok: - return False + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) + hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) + hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - hass.data[DOMAIN].pop(entry.entry_id) - - if len(hass.data[DOMAIN]) != 0: - return True - - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - hass.services.async_remove(DOMAIN, SERVICE_SAVE_VIDEO) - hass.services.async_remove(DOMAIN, SERVICE_SEND_PIN) - - return True + return unload_ok async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" - blink: Blink = hass.data[DOMAIN][entry.entry_id] + blink: Blink = hass.data[DOMAIN][entry.entry_id].api blink.refresh_rate = entry.options[CONF_SCAN_INTERVAL] @@ -186,13 +182,12 @@ async def async_handle_save_video_service( if not hass.config.is_allowed_path(video_path): _LOGGER.error("Can't write %s, no access to path!", video_path) return - try: - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: await all_cameras[camera_name].video_to_file(video_path) - - except OSError as err: - _LOGGER.error("Can't write image to file: %s", err) + except OSError as err: + _LOGGER.error("Can't write image to file: %s", err) async def async_handle_save_recent_clips_service( @@ -204,10 +199,9 @@ async def async_handle_save_recent_clips_service( if not hass.config.is_allowed_path(clips_dir): _LOGGER.error("Can't write to directory %s, no access to path!", clips_dir) return - - try: - all_cameras = hass.data[DOMAIN][entry.entry_id].cameras - if camera_name in all_cameras: + all_cameras = hass.data[DOMAIN][entry.entry_id].api.cameras + if camera_name in all_cameras: + try: await all_cameras[camera_name].save_recent_clips(output_dir=clips_dir) - except OSError as err: - _LOGGER.error("Can't write recent clips to directory: %s", err) + except OSError as err: + _LOGGER.error("Can't write recent clips to directory: %s", err) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index 2249c9bf16f..c789d7cdd6f 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import logging -from blinkpy.blinkpy import Blink +from blinkpy.blinkpy import Blink, BlinkSyncModule from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, @@ -16,12 +16,14 @@ from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -32,25 +34,31 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Blink Alarm Control Panels.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] sync_modules = [] - for sync_name, sync_module in data.sync.items(): - sync_modules.append(BlinkSyncModuleHA(data, sync_name, sync_module)) - async_add_entities(sync_modules, update_before_add=True) + for sync_name, sync_module in coordinator.api.sync.items(): + sync_modules.append(BlinkSyncModuleHA(coordinator, sync_name, sync_module)) + async_add_entities(sync_modules) -class BlinkSyncModuleHA(AlarmControlPanelEntity): +class BlinkSyncModuleHA( + CoordinatorEntity[BlinkUpdateCoordinator], AlarmControlPanelEntity +): """Representation of a Blink Alarm Control Panel.""" _attr_icon = ICON _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - _attr_name = None _attr_has_entity_name = True + _attr_name = None - def __init__(self, data, name: str, sync) -> None: + def __init__( + self, coordinator: BlinkUpdateCoordinator, name: str, sync: BlinkSyncModule + ) -> None: """Initialize the alarm control panel.""" - self.data: Blink = data + super().__init__(coordinator) + self.api: Blink = coordinator.api + self._coordinator = coordinator self.sync = sync self._name: str = name self._attr_unique_id: str = sync.serial @@ -59,49 +67,42 @@ class BlinkSyncModuleHA(AlarmControlPanelEntity): name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, ) + self._update_attr() - async def async_update(self) -> None: - """Update the state of the device.""" - if self.data.check_if_ok_to_update(): - _LOGGER.debug( - "Initiating a blink.refresh() from BlinkSyncModule('%s') (%s)", - self._name, - self.data, - ) - try: - await self.data.refresh(force=True) - self._attr_available = True - except asyncio.TimeoutError: - self._attr_available = False + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() - _LOGGER.info("Updating State of Blink Alarm Control Panel '%s'", self._name) - - self.sync.attributes["network_info"] = self.data.networks + @callback + def _update_attr(self) -> None: + """Update attributes for alarm control panel.""" + self.sync.attributes["network_info"] = self.api.networks self.sync.attributes["associated_cameras"] = list(self.sync.cameras) self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes - - @property - def state(self) -> StateType: - """Return state of alarm.""" - return STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + self._attr_state = ( + STATE_ALARM_ARMED_AWAY if self.sync.arm else STATE_ALARM_DISARMED + ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: await self.sync.async_arm(False) - await self.sync.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False - self.async_write_ha_state() + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + await self._coordinator.async_refresh() async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" try: await self.sync.async_arm(True) - await self.sync.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera away") from er + + await self._coordinator.async_refresh() self.async_write_ha_state() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 51df22dbf0e..65e454e4434 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -10,9 +10,10 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( DEFAULT_BRAND, @@ -21,6 +22,7 @@ from .const import ( TYPE_CAMERA_ARMED, TYPE_MOTION_DETECTED, ) +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -45,28 +47,31 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the blink binary sensors.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkBinarySensor(data, camera, description) - for camera in data.cameras + BlinkBinarySensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in BINARY_SENSORS_TYPES ] async_add_entities(entities) -class BlinkBinarySensor(BinarySensorEntity): +class BlinkBinarySensor(CoordinatorEntity[BlinkUpdateCoordinator], BinarySensorEntity): """Representation of a Blink binary sensor.""" _attr_has_entity_name = True def __init__( - self, data, camera, description: BinarySensorEntityDescription + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: BinarySensorEntityDescription, ) -> None: """Initialize the sensor.""" - self.data = data + super().__init__(coordinator) self.entity_description = description - self._camera = data.cameras[camera] + self._camera = coordinator.api.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._camera.serial)}, @@ -74,10 +79,17 @@ class BlinkBinarySensor(BinarySensorEntity): manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attrs() - @property - def is_on(self) -> bool | None: - """Update sensor state.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle update from data coordinator.""" + self._update_attrs() + super()._handle_coordinator_update() + + @callback + def _update_attrs(self) -> None: + """Update attributes for binary sensor.""" is_on = self._camera.attributes[self.entity_description.key] _LOGGER.debug( "'%s' %s = %s", @@ -87,4 +99,4 @@ class BlinkBinarySensor(BinarySensorEntity): ) if self.entity_description.key == TYPE_BATTERY: is_on = is_on != "ok" - return is_on + self._attr_is_on = is_on diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index bf7af4fe619..4ff0ba86db9 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -12,11 +12,14 @@ from requests.exceptions import ChunkedEncodingError from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, SERVICE_TRIGGER +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -28,9 +31,10 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a Blink Camera.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkCamera(data, name, camera) for name, camera in data.cameras.items() + BlinkCamera(coordinator, name, camera) + for name, camera in coordinator.api.cameras.items() ] async_add_entities(entities) @@ -39,16 +43,17 @@ async def async_setup_entry( platform.async_register_entity_service(SERVICE_TRIGGER, {}, "trigger_camera") -class BlinkCamera(Camera): +class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """An implementation of a Blink Camera.""" _attr_has_entity_name = True _attr_name = None - def __init__(self, data, name, camera) -> None: + def __init__(self, coordinator: BlinkUpdateCoordinator, name, camera) -> None: """Initialize a camera.""" - super().__init__() - self.data = data + super().__init__(coordinator) + Camera.__init__(self) + self._coordinator = coordinator self._camera = camera self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( @@ -68,17 +73,22 @@ class BlinkCamera(Camera): """Enable motion detection for the camera.""" try: await self._camera.async_arm(True) - await self.data.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to arm camera") from er + + self._camera.motion_enabled = True + await self._coordinator.async_refresh() async def async_disable_motion_detection(self) -> None: """Disable motion detection for the camera.""" try: await self._camera.async_arm(False) - await self.data.refresh(force=True) - except asyncio.TimeoutError: - self._attr_available = False + except asyncio.TimeoutError as er: + raise HomeAssistantError("Blink failed to disarm camera") from er + + self._camera.motion_enabled = False + await self._coordinator.async_refresh() @property def motion_detection_enabled(self) -> bool: diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index d58920562f4..7de42a80efc 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -7,7 +7,6 @@ DEVICE_ID = "Home Assistant" CONF_MIGRATE = "migrate" CONF_CAMERA = "camera" CONF_ALARM_CONTROL_PANEL = "alarm_control_panel" - DEFAULT_BRAND = "Blink" DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com" DEFAULT_SCAN_INTERVAL = 300 diff --git a/homeassistant/components/blink/coordinator.py b/homeassistant/components/blink/coordinator.py new file mode 100644 index 00000000000..d3f7551e1b2 --- /dev/null +++ b/homeassistant/components/blink/coordinator.py @@ -0,0 +1,33 @@ +"""Blink Coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from blinkpy.blinkpy import Blink + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class BlinkUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """BlinkUpdateCoordinator - In charge of downloading the data for a site.""" + + def __init__(self, hass: HomeAssistant, api: Blink) -> None: + """Initialize the data service.""" + self.api = api + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Async update wrapper.""" + return await self.api.refresh(force=True) diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index c979c9b6a53..9453d3b6d6b 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -1,8 +1,6 @@ """Support for Blink system camera sensors.""" from __future__ import annotations -from datetime import date, datetime -from decimal import Decimal import logging from homeassistant.components.sensor import ( @@ -17,12 +15,13 @@ from homeassistant.const import ( EntityCategory, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DEFAULT_BRAND, DOMAIN, TYPE_TEMPERATURE, TYPE_WIFI_STRENGTH +from .coordinator import BlinkUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -49,26 +48,32 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Initialize a Blink sensor.""" - data = hass.data[DOMAIN][config.entry_id] + coordinator: BlinkUpdateCoordinator = hass.data[DOMAIN][config.entry_id] entities = [ - BlinkSensor(data, camera, description) - for camera in data.cameras + BlinkSensor(coordinator, camera, description) + for camera in coordinator.api.cameras for description in SENSOR_TYPES ] async_add_entities(entities) -class BlinkSensor(SensorEntity): +class BlinkSensor(CoordinatorEntity[BlinkUpdateCoordinator], SensorEntity): """A Blink camera sensor.""" _attr_has_entity_name = True - def __init__(self, data, camera, description: SensorEntityDescription) -> None: + def __init__( + self, + coordinator: BlinkUpdateCoordinator, + camera, + description: SensorEntityDescription, + ) -> None: """Initialize sensors from Blink camera.""" + super().__init__(coordinator) self.entity_description = description - self.data = data - self._camera = data.cameras[camera] + + self._camera = coordinator.api.cameras[camera] self._attr_unique_id = f"{self._camera.serial}-{description.key}" self._sensor_key = ( "temperature_calibrated" @@ -81,12 +86,19 @@ class BlinkSensor(SensorEntity): manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, ) + self._update_attr() - @property - def native_value(self) -> StateType | date | datetime | Decimal: - """Retrieve sensor data from the camera.""" + @callback + def _handle_coordinator_update(self) -> None: + """Handle coordinator update.""" + self._update_attr() + super()._handle_coordinator_update() + + @callback + def _update_attr(self) -> None: + """Update attributes for sensor.""" try: - native_value = self._camera.attributes[self._sensor_key] + self._attr_native_value = self._camera.attributes[self._sensor_key] _LOGGER.debug( "'%s' %s = %s", self._camera.attributes["name"], @@ -94,8 +106,7 @@ class BlinkSensor(SensorEntity): self._attr_native_value, ) except KeyError: - native_value = None + self._attr_native_value = None _LOGGER.error( "%s not a valid camera attribute. Did the API change?", self._sensor_key ) - return native_value From c7d2499a52c92809ee652bfd5c1b8a7a772d6ede Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:47:12 +0200 Subject: [PATCH 737/968] Bump plugwise to v0.33.1 (#102052) --- homeassistant/components/plugwise/climate.py | 10 ---------- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 4 ---- .../plugwise/fixtures/m_adam_cooling/all_data.json | 5 +---- .../plugwise/fixtures/m_adam_heating/all_data.json | 3 --- tests/components/plugwise/test_climate.py | 2 +- 8 files changed, 5 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 610ffa34d7c..32146c2753f 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -120,16 +120,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" - # When control_state is present, prefer this data - if (control_state := self.device.get("control_state")) == "cooling": - return HVACAction.COOLING - # Support preheating state as heating, - # until preheating is added as a separate state - if control_state in ["heating", "preheating"]: - return HVACAction.HEATING - if control_state == "off": - return HVACAction.IDLE - heater: str | None = self.coordinator.data.gateway["heater_id"] if heater: heater_data = self.coordinator.data.devices[heater] diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index c8c678d6aae..b4cc418cc7e 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.0"], + "requirements": ["plugwise==0.33.1"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 91e506a487f..c886ab7e2b3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1455,7 +1455,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.0 +plugwise==0.33.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 06f4e7260fe..e5a5492059d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1115,7 +1115,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.0 +plugwise==0.33.1 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index 4dda9af3b54..ba00e3928d7 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -4,7 +4,6 @@ "active_preset": "no_frost", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -101,7 +100,6 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -159,7 +157,6 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", @@ -271,7 +268,6 @@ "active_preset": "home", "available": true, "available_schedules": ["None"], - "control_state": "off", "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index ac7e602821e..af8c012cae3 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -53,7 +53,6 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "cooling", "dev_class": "thermostat", "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -88,7 +87,6 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "regulation_mode": "cooling", "regulation_modes": [ "heating", "off", @@ -96,7 +94,7 @@ "bleeding_hot", "cooling" ], - "select_regulation_mode": "heating", + "select_regulation_mode": "cooling", "sensors": { "outdoor_temperature": 29.65 }, @@ -107,7 +105,6 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index a4923b1c549..efefa95d45c 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -58,7 +58,6 @@ "active_preset": "asleep", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "heating", "dev_class": "thermostat", "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", @@ -91,7 +90,6 @@ "mac_address": "012345670001", "model": "Gateway", "name": "Adam", - "regulation_mode": "heating", "regulation_modes": ["heating", "off", "bleeding_cold", "bleeding_hot"], "select_regulation_mode": "heating", "sensors": { @@ -104,7 +102,6 @@ "active_preset": "home", "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], - "control_state": "off", "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index c73bd5b6190..496eeaae084 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -64,7 +64,7 @@ async def test_adam_2_climate_entity_attributes( state = hass.states.get("climate.lisa_badkamer") assert state assert state.state == HVACMode.AUTO - assert state.attributes["hvac_action"] == "idle" + assert state.attributes["hvac_action"] == "heating" assert state.attributes["hvac_modes"] == [HVACMode.HEAT, HVACMode.AUTO] From 54bcd70878668744d7eccabfc81f40a19117e38a Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Mon, 23 Oct 2023 15:49:48 +0200 Subject: [PATCH 738/968] Increase timeouts in Minecraft Server (#101784) --- .../components/minecraft_server/__init__.py | 6 ++--- .../components/minecraft_server/api.py | 25 +++++++++++++++---- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 53324e6d5a4..4e5ab9290f0 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -39,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) except MinecraftServerAddressError as error: raise ConfigEntryError( - f"Server address in configuration entry is invalid (error: {error})" + f"Server address in configuration entry is invalid: {error}" ) from error # Create coordinator instance. @@ -109,7 +109,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> except MinecraftServerAddressError as error: host_only_lookup_success = False _LOGGER.debug( - "Hostname (without port) cannot be parsed (error: %s), trying again with port", + "Hostname (without port) cannot be parsed, trying again with port: %s", error, ) @@ -119,7 +119,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> MinecraftServer(MinecraftServerType.JAVA_EDITION, address) except MinecraftServerAddressError as error: _LOGGER.exception( - "Can't migrate configuration entry due to error while parsing server address (error: %s), try again later", + "Can't migrate configuration entry due to error while parsing server address, try again later: %s", error, ) return False diff --git a/homeassistant/components/minecraft_server/api.py b/homeassistant/components/minecraft_server/api.py index d0bd679def8..4ab7865f369 100644 --- a/homeassistant/components/minecraft_server/api.py +++ b/homeassistant/components/minecraft_server/api.py @@ -11,6 +11,10 @@ from mcstatus.status_response import BedrockStatusResponse, JavaStatusResponse _LOGGER = logging.getLogger(__name__) +LOOKUP_TIMEOUT: float = 10 +DATA_UPDATE_TIMEOUT: float = 10 +DATA_UPDATE_RETRIES: int = 3 + @dataclass class MinecraftServerData: @@ -57,14 +61,17 @@ class MinecraftServer: """Initialize server instance.""" try: if server_type == MinecraftServerType.JAVA_EDITION: - self._server = JavaServer.lookup(address) + self._server = JavaServer.lookup(address, timeout=LOOKUP_TIMEOUT) else: - self._server = BedrockServer.lookup(address) + self._server = BedrockServer.lookup(address, timeout=LOOKUP_TIMEOUT) except (ValueError, LifetimeTimeout) as error: raise MinecraftServerAddressError( - f"{server_type} server address '{address}' is invalid (error: {error})" + f"Lookup of '{address}' failed: {self._get_error_message(error)}" ) from error + self._server.timeout = DATA_UPDATE_TIMEOUT + self._address = address + _LOGGER.debug( "%s server instance created with address '%s'", server_type, address ) @@ -83,10 +90,10 @@ class MinecraftServer: status_response: BedrockStatusResponse | JavaStatusResponse try: - status_response = await self._server.async_status() + status_response = await self._server.async_status(tries=DATA_UPDATE_RETRIES) except OSError as error: raise MinecraftServerConnectionError( - f"Fetching data from the server failed (error: {error})" + f"Status request to '{self._address}' failed: {self._get_error_message(error)}" ) from error if isinstance(status_response, JavaStatusResponse): @@ -132,3 +139,11 @@ class MinecraftServer: game_mode=status_response.gamemode, map_name=status_response.map_name, ) + + def _get_error_message(self, error: BaseException) -> str: + """Get error message of an exception.""" + if not str(error): + # Fallback to error type in case of an empty error message. + return repr(error) + + return str(error) From a52761171f2c4cc12a18fba0cd13fec4deb7bcf1 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 23 Oct 2023 12:12:34 -0500 Subject: [PATCH 739/968] No cooldown when wake words have the same id (#101846) * No cooldown when wake words have the same id * Use wake word entity id in cooldown decision --- .../components/assist_pipeline/__init__.py | 5 +- .../components/assist_pipeline/pipeline.py | 5 +- tests/components/assist_pipeline/conftest.py | 60 ++++- .../snapshots/test_websocket.ambr | 170 ++++++++++++++ .../assist_pipeline/test_websocket.py | 222 +++++++++++++++++- 5 files changed, 452 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index fab4c3178bc..64fe9e1f5f4 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -9,7 +9,7 @@ from homeassistant.components import stt from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DOMAIN +from .const import CONF_DEBUG_RECORDING_DIR, DATA_CONFIG, DATA_LAST_WAKE_UP, DOMAIN from .error import PipelineNotFound from .pipeline import ( AudioSettings, @@ -58,6 +58,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" hass.data[DATA_CONFIG] = config.get(DOMAIN, {}) + # wake_word_id -> timestamp of last detection (monotonic_ns) + hass.data[DATA_LAST_WAKE_UP] = {} + await async_setup_pipeline_store(hass) async_register_websocket_api(hass) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 6ec031baf3b..bb34a223af6 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -681,7 +681,8 @@ class PipelineRun: wake_word_output: dict[str, Any] = {} else: # Avoid duplicate detections by checking cooldown - last_wake_up = self.hass.data.get(DATA_LAST_WAKE_UP) + wake_up_key = f"{self.wake_word_entity_id}.{result.wake_word_id}" + last_wake_up = self.hass.data[DATA_LAST_WAKE_UP].get(wake_up_key) if last_wake_up is not None: sec_since_last_wake_up = time.monotonic() - last_wake_up if sec_since_last_wake_up < wake_word_settings.cooldown_seconds: @@ -689,7 +690,7 @@ class PipelineRun: raise WakeWordDetectionAborted # Record last wake up time to block duplicate detections - self.hass.data[DATA_LAST_WAKE_UP] = time.monotonic() + self.hass.data[DATA_LAST_WAKE_UP][wake_up_key] = time.monotonic() if result.queued_audio: # Add audio that was pending at detection. diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 1a3144ee069..97f80a33d1d 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -181,6 +181,49 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): url_path = "wake_word.test" _attr_name = "test" + alternate_detections = False + detected_wake_word_index = 0 + + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: + """Return a list of supported wake words.""" + return [ + wake_word.WakeWord(id="test_ww", name="Test Wake Word"), + wake_word.WakeWord(id="test_ww_2", name="Test Wake Word 2"), + ] + + async def _async_process_audio_stream( + self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None + ) -> wake_word.DetectionResult | None: + """Try to detect wake word(s) in an audio stream with timestamps.""" + wake_words = await self.get_supported_wake_words() + + if self.alternate_detections: + detected_id = wake_words[self.detected_wake_word_index].id + self.detected_wake_word_index = (self.detected_wake_word_index + 1) % len( + wake_words + ) + else: + detected_id = wake_words[0].id + + async for chunk, timestamp in stream: + if chunk.startswith(b"wake word"): + return wake_word.DetectionResult( + wake_word_id=detected_id, + timestamp=timestamp, + queued_audio=[(b"queued audio", 0)], + ) + + # Not detected + return None + + +class MockWakeWordEntity2(wake_word.WakeWordDetectionEntity): + """Second mock wake word entity to test cooldown.""" + + fail_process_audio = False + url_path = "wake_word.test2" + _attr_name = "test2" + async def get_supported_wake_words(self) -> list[wake_word.WakeWord]: """Return a list of supported wake words.""" return [wake_word.WakeWord(id="test_ww", name="Test Wake Word")] @@ -189,12 +232,12 @@ class MockWakeWordEntity(wake_word.WakeWordDetectionEntity): self, stream: AsyncIterable[tuple[bytes, int]], wake_word_id: str | None ) -> wake_word.DetectionResult | None: """Try to detect wake word(s) in an audio stream with timestamps.""" - if wake_word_id is None: - wake_word_id = (await self.get_supported_wake_words())[0].id + wake_words = await self.get_supported_wake_words() + async for chunk, timestamp in stream: if chunk.startswith(b"wake word"): return wake_word.DetectionResult( - wake_word_id=wake_word_id, + wake_word_id=wake_words[0].id, timestamp=timestamp, queued_audio=[(b"queued audio", 0)], ) @@ -209,6 +252,12 @@ async def mock_wake_word_provider_entity(hass) -> MockWakeWordEntity: return MockWakeWordEntity() +@pytest.fixture +async def mock_wake_word_provider_entity2(hass) -> MockWakeWordEntity2: + """Mock wake word provider.""" + return MockWakeWordEntity2() + + class MockFlow(ConfigFlow): """Test flow.""" @@ -229,6 +278,7 @@ async def init_supporting_components( mock_stt_provider_entity: MockSttProviderEntity, mock_tts_provider: MockTTSProvider, mock_wake_word_provider_entity: MockWakeWordEntity, + mock_wake_word_provider_entity2: MockWakeWordEntity2, config_flow_fixture, ): """Initialize relevant components with empty configs.""" @@ -265,7 +315,9 @@ async def init_supporting_components( async_add_entities: AddEntitiesCallback, ) -> None: """Set up test wake word platform via config entry.""" - async_add_entities([mock_wake_word_provider_entity]) + async_add_entities( + [mock_wake_word_provider_entity, mock_wake_word_provider_entity2] + ) mock_integration( hass, diff --git a/tests/components/assist_pipeline/snapshots/test_websocket.ambr b/tests/components/assist_pipeline/snapshots/test_websocket.ambr index b8c668f3fd0..9eb7e1e5a05 100644 --- a/tests/components/assist_pipeline/snapshots/test_websocket.ambr +++ b/tests/components/assist_pipeline/snapshots/test_websocket.ambr @@ -717,3 +717,173 @@ 'message': '', }) # --- +# name: test_wake_word_cooldown_different_entities + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_entities.3 + dict({ + 'entity_id': 'wake_word.test2', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_entities.4 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_entities.5 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_ids.3 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_different_ids.4 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww', + }), + }) +# --- +# name: test_wake_word_cooldown_different_ids.5 + dict({ + 'wake_word_output': dict({ + 'timestamp': 0, + 'wake_word_id': 'test_ww_2', + }), + }) +# --- +# name: test_wake_word_cooldown_same_id + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_same_id.1 + dict({ + 'language': 'en', + 'pipeline': , + 'runner_data': dict({ + 'stt_binary_handler_id': 1, + 'timeout': 300, + }), + }) +# --- +# name: test_wake_word_cooldown_same_id.2 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- +# name: test_wake_word_cooldown_same_id.3 + dict({ + 'entity_id': 'wake_word.test', + 'metadata': dict({ + 'bit_rate': 16, + 'channel': 1, + 'codec': 'pcm', + 'format': 'wav', + 'sample_rate': 16000, + }), + 'timeout': 3, + }) +# --- diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index 28b31e5b19c..9a4e78a29af 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -9,7 +9,7 @@ from homeassistant.components.assist_pipeline.pipeline import Pipeline, Pipeline from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .conftest import MockWakeWordEntity +from .conftest import MockWakeWordEntity, MockWakeWordEntity2 from tests.typing import WebSocketGenerator @@ -1809,14 +1809,14 @@ async def test_audio_pipeline_with_enhancements( assert msg["result"] == {"events": events} -async def test_wake_word_cooldown( +async def test_wake_word_cooldown_same_id( hass: HomeAssistant, init_components, mock_wake_word_provider_entity: MockWakeWordEntity, hass_ws_client: WebSocketGenerator, snapshot: SnapshotAssertion, ) -> None: - """Test that duplicate wake word detections are blocked during the cooldown period.""" + """Test that duplicate wake word detections with the same id are blocked during the cooldown period.""" client_1 = await hass_ws_client(hass) client_2 = await hass_ws_client(hass) @@ -1888,3 +1888,219 @@ async def test_wake_word_cooldown( # One should be a wake up, one should be an error assert {event_type_1, event_type_2} == {"wake_word-end", "error"} + + +async def test_wake_word_cooldown_different_ids( + hass: HomeAssistant, + init_components, + mock_wake_word_provider_entity: MockWakeWordEntity, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that duplicate wake word detections are allowed with different ids.""" + with patch.object(mock_wake_word_provider_entity, "alternate_detections", True): + client_1 = await hass_ws_client(hass) + client_2 = await hass_ws_client(hass) + + await client_1.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + await client_2.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # result + msg = await client_1.receive_json() + assert msg["success"], msg + + msg = await client_2.receive_json() + assert msg["success"], msg + + # run start + msg = await client_1.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_1 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_2 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + # wake_word + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + # Wake both up at the same time, but they will have different wake word ids + await client_1.send_bytes(bytes([handler_id_1]) + b"wake word") + await client_2.send_bytes(bytes([handler_id_2]) + b"wake word") + + # Get response events + msg = await client_1.receive_json() + event_type_1 = msg["event"]["type"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + event_type_2 = msg["event"]["type"] + assert msg["event"]["data"] == snapshot + + # Both should wake up now + assert {event_type_1, event_type_2} == {"wake_word-end"} + + +async def test_wake_word_cooldown_different_entities( + hass: HomeAssistant, + init_components, + mock_wake_word_provider_entity: MockWakeWordEntity, + mock_wake_word_provider_entity2: MockWakeWordEntity2, + hass_ws_client: WebSocketGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test that duplicate wake word detections are allowed with different entities.""" + client_pipeline = await hass_ws_client(hass) + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "pipeline_with_wake_word_1", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": mock_wake_word_provider_entity.entity_id, + "wake_word_id": "test_ww", + } + ) + msg = await client_pipeline.receive_json() + assert msg["success"] + pipeline_id_1 = msg["result"]["id"] + + await client_pipeline.send_json_auto_id( + { + "type": "assist_pipeline/pipeline/create", + "conversation_engine": "homeassistant", + "conversation_language": "en-US", + "language": "en", + "name": "pipeline_with_wake_word_2", + "stt_engine": "test", + "stt_language": "en-US", + "tts_engine": "test", + "tts_language": "en-US", + "tts_voice": "Arnold Schwarzenegger", + "wake_word_entity": mock_wake_word_provider_entity2.entity_id, + "wake_word_id": "test_ww", + } + ) + msg = await client_pipeline.receive_json() + assert msg["success"] + pipeline_id_2 = msg["result"]["id"] + + # Wake word clients + client_1 = await hass_ws_client(hass) + client_2 = await hass_ws_client(hass) + + await client_1.send_json_auto_id( + { + "type": "assist_pipeline/run", + "pipeline": pipeline_id_1, + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # Use different wake word entity + await client_2.send_json_auto_id( + { + "type": "assist_pipeline/run", + "pipeline": pipeline_id_2, + "start_stage": "wake_word", + "end_stage": "tts", + "input": { + "sample_rate": 16000, + "no_vad": True, + "no_chunking": True, + }, + } + ) + + # result + msg = await client_1.receive_json() + assert msg["success"], msg + + msg = await client_2.receive_json() + assert msg["success"], msg + + # run start + msg = await client_1.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_1 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "run-start" + msg["event"]["data"]["pipeline"] = ANY + handler_id_2 = msg["event"]["data"]["runner_data"]["stt_binary_handler_id"] + assert msg["event"]["data"] == snapshot + + # wake_word + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-start" + assert msg["event"]["data"] == snapshot + + # Wake both up at the same time. + # They will have the same wake word id, but different entities. + await client_1.send_bytes(bytes([handler_id_1]) + b"wake word") + await client_2.send_bytes(bytes([handler_id_2]) + b"wake word") + + # Get response events + msg = await client_1.receive_json() + assert msg["event"]["type"] == "wake_word-end", msg + ww_id_1 = msg["event"]["data"]["wake_word_output"]["wake_word_id"] + assert msg["event"]["data"] == snapshot + + msg = await client_2.receive_json() + assert msg["event"]["type"] == "wake_word-end", msg + ww_id_2 = msg["event"]["data"]["wake_word_output"]["wake_word_id"] + assert msg["event"]["data"] == snapshot + + # Wake words should be the same + assert ww_id_1 == ww_id_2 From c555fe44625e65605f02919ccf0626bff935cfdd Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 23 Oct 2023 11:01:25 -0700 Subject: [PATCH 740/968] Refactor ZHA IkeaFan (#101858) IkeaFan refactor --- homeassistant/components/zha/fan.py | 137 ++++++++++------------------ 1 file changed, 46 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 5473b7f0183..b8cf2cd0339 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -45,9 +45,6 @@ PRESET_MODE_SMART = "smart" SPEED_RANGE = (1, 3) # off is not included PRESET_MODES_TO_NAME = {4: PRESET_MODE_ON, 5: PRESET_MODE_AUTO, 6: PRESET_MODE_SMART} -NAME_TO_PRESET_MODE = {v: k for k, v in PRESET_MODES_TO_NAME.items()} -PRESET_MODES = list(NAME_TO_PRESET_MODE) - DEFAULT_ON_PERCENTAGE = 50 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) @@ -85,12 +82,32 @@ class BaseFan(FanEntity): @property def preset_modes(self) -> list[str]: """Return the available preset modes.""" - return PRESET_MODES + return list(self.preset_modes_to_name.values()) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return PRESET_MODES_TO_NAME + + @property + def preset_name_to_mode(self) -> dict[str, int]: + """Return a dict from preset name to mode.""" + return {v: k for k, v in self.preset_modes_to_name.items()} + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return DEFAULT_ON_PERCENTAGE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return SPEED_RANGE @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) + return int_states_in_range(self.speed_range) async def async_turn_on( self, @@ -100,7 +117,7 @@ class BaseFan(FanEntity): ) -> None: """Turn the entity on.""" if percentage is None: - percentage = DEFAULT_ON_PERCENTAGE + percentage = self.default_on_percentage await self.async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: @@ -109,7 +126,7 @@ class BaseFan(FanEntity): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + fan_mode = math.ceil(percentage_to_ranged_value(self.speed_range, percentage)) await self._async_set_fan_mode(fan_mode) async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -119,7 +136,7 @@ class BaseFan(FanEntity): f"The preset_mode {preset_mode} is not a valid preset_mode:" f" {self.preset_modes}" ) - await self._async_set_fan_mode(NAME_TO_PRESET_MODE[preset_mode]) + await self._async_set_fan_mode(self.preset_name_to_mode[preset_mode]) @abstractmethod async def _async_set_fan_mode(self, fan_mode: int) -> None: @@ -151,19 +168,19 @@ class ZhaFan(BaseFan, ZhaEntity): """Return the current speed percentage.""" if ( self._fan_cluster_handler.fan_mode is None - or self._fan_cluster_handler.fan_mode > SPEED_RANGE[1] + or self._fan_cluster_handler.fan_mode > self.speed_range[1] ): return None if self._fan_cluster_handler.fan_mode == 0: return 0 return ranged_value_to_percentage( - SPEED_RANGE, self._fan_cluster_handler.fan_mode + self.speed_range, self._fan_cluster_handler.fan_mode ) @property def preset_mode(self) -> str | None: """Return the current preset mode.""" - return PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) + return self.preset_modes_to_name.get(self._fan_cluster_handler.fan_mode) @callback def async_set_state(self, attr_id, attr_name, value): @@ -252,95 +269,33 @@ IKEA_PRESET_MODES_TO_NAME = { 9: "Speed 4.5", 10: "Speed 5", } -IKEA_NAME_TO_PRESET_MODE = {v: k for k, v in IKEA_PRESET_MODES_TO_NAME.items()} -IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) @MULTI_MATCH( cluster_handler_names="ikea_airpurifier", models={"STARKVIND Air purifier", "STARKVIND Air purifier table"}, ) -class IkeaFan(BaseFan, ZhaEntity): - """Representation of a ZHA fan.""" +class IkeaFan(ZhaFan): + """Representation of an Ikea fan.""" - def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs): + def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: """Init this sensor.""" super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._fan_cluster_handler = self.cluster_handlers.get("ikea_airpurifier") - async def async_added_to_hass(self) -> None: - """Run when about to be added to hass.""" - await super().async_added_to_hass() - self.async_accept_signal( - self._fan_cluster_handler, SIGNAL_ATTR_UPDATED, self.async_set_state + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return IKEA_PRESET_MODES_TO_NAME + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return IKEA_SPEED_RANGE + + @property + def default_on_percentage(self) -> int: + """Return the default on percentage.""" + return int( + (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] ) - - @property - def preset_modes(self) -> list[str]: - """Return the available preset modes.""" - return IKEA_PRESET_MODES - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(IKEA_SPEED_RANGE) - - async def async_set_percentage(self, percentage: int) -> None: - """Set the speed percentage of the fan.""" - fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) - await self._async_set_fan_mode(fan_mode) - - async def async_set_preset_mode(self, preset_mode: str) -> None: - """Set the preset mode for the fan.""" - if preset_mode not in self.preset_modes: - raise NotValidPresetModeError( - f"The preset_mode {preset_mode} is not a valid preset_mode:" - f" {self.preset_modes}" - ) - await self._async_set_fan_mode(IKEA_NAME_TO_PRESET_MODE[preset_mode]) - - @property - def percentage(self) -> int | None: - """Return the current speed percentage.""" - if ( - self._fan_cluster_handler.fan_mode is None - or self._fan_cluster_handler.fan_mode > IKEA_SPEED_RANGE[1] - ): - return None - if self._fan_cluster_handler.fan_mode == 0: - return 0 - return ranged_value_to_percentage( - IKEA_SPEED_RANGE, self._fan_cluster_handler.fan_mode - ) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode.""" - return IKEA_PRESET_MODES_TO_NAME.get(self._fan_cluster_handler.fan_mode) - - async def async_turn_on( - self, - percentage: int | None = None, - preset_mode: str | None = None, - **kwargs: Any, - ) -> None: - """Turn the entity on.""" - if percentage is None: - percentage = int( - (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[PRESET_MODE_AUTO] - ) - await self.async_set_percentage(percentage) - - async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the entity off.""" - await self.async_set_percentage(0) - - @callback - def async_set_state(self, attr_id, attr_name, value): - """Handle state update from cluster handler.""" - self.async_write_ha_state() - - async def _async_set_fan_mode(self, fan_mode: int) -> None: - """Set the fan mode for the fan.""" - await self._fan_cluster_handler.async_set_speed(fan_mode) - self.async_set_state(0, "fan_mode", fan_mode) From 7a009ed6cdef06cfe1d3d9559f19b2c4748465a5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Oct 2023 20:26:56 +0200 Subject: [PATCH 741/968] Don't duplicate core services in hassio (#102593) --- homeassistant/components/hassio/__init__.py | 69 +++---------------- .../components/homeassistant/__init__.py | 41 ++++++++--- .../components/homeassistant/const.py | 6 ++ homeassistant/const.py | 3 - tests/components/hassio/test_init.py | 1 + tests/components/homeassistant/test_init.py | 4 +- 6 files changed, 51 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 91b87416c15..78e9c40cebd 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -13,25 +13,18 @@ from typing import Any, NamedTuple import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN -from homeassistant.components import panel_custom, persistent_notification -from homeassistant.components.homeassistant import ( - SERVICE_CHECK_CONFIG, - SHUTDOWN_SERVICES, -) -import homeassistant.config as conf_util +from homeassistant.components import panel_custom +from homeassistant.components.homeassistant import async_set_stop_handler from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MANUFACTURER, ATTR_NAME, EVENT_CORE_CONFIG_UPDATE, HASSIO_USER_NAME, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, Platform, ) from homeassistant.core import ( CALLBACK_TYPE, - DOMAIN as HASS_DOMAIN, HassJob, HomeAssistant, ServiceCall, @@ -39,11 +32,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - recorder, -) +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later @@ -573,53 +562,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: # Fetch data await update_info_data() - async def async_handle_core_service(call: ServiceCall) -> None: - """Service handler for handling core services.""" - if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( - hass - ): - _LOGGER.error( - "The system cannot %s while a database upgrade is in progress", - call.service, - ) - raise HomeAssistantError( - f"The system cannot {call.service} " - "while a database upgrade is in progress." - ) - - if call.service == SERVICE_HOMEASSISTANT_STOP: - await hassio.stop_homeassistant() - return - - errors = await conf_util.async_check_ha_config_file(hass) - - if errors: - _LOGGER.error( - "The system cannot %s because the configuration is not valid: %s", - call.service, - errors, - ) - persistent_notification.async_create( - hass, - "Config error. See [the logs](/config/logs) for details.", - "Config validating", - f"{HASS_DOMAIN}.check_config", - ) - raise HomeAssistantError( - f"The system cannot {call.service} " - f"because the configuration is not valid: {errors}" - ) - - if call.service == SERVICE_HOMEASSISTANT_RESTART: + async def _async_stop(hass: HomeAssistant, restart: bool) -> None: + """Stop or restart home assistant.""" + if restart: await hassio.restart_homeassistant() + else: + await hassio.stop_homeassistant() - # Mock core services - for service in ( - SERVICE_HOMEASSISTANT_STOP, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_CHECK_CONFIG, - ): - hass.services.async_register(HASS_DOMAIN, service, async_handle_core_service) + # Set a custom handler for the homeassistant.restart and homeassistant.stop services + async_set_stop_handler(hass, _async_stop) # Init discovery Hass.io feature async_setup_discovery_view(hass, hassio) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index e4032ad954d..5b26cb29ded 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -1,7 +1,9 @@ """Integration providing core pieces of infrastructure.""" import asyncio +from collections.abc import Callable, Coroutine import itertools as it import logging +from typing import Any import voluptuous as vol @@ -14,8 +16,6 @@ from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, RESTART_EXIT_CODE, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, SERVICE_RELOAD, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, @@ -34,7 +34,13 @@ from homeassistant.helpers.service import ( from homeassistant.helpers.template import async_load_custom_templates from homeassistant.helpers.typing import ConfigType -from .const import DATA_EXPOSED_ENTITIES, DOMAIN +from .const import ( + DATA_EXPOSED_ENTITIES, + DATA_STOP_HANDLER, + DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, +) from .exposed_entities import ExposedEntities ATTR_ENTRY_ID = "entry_id" @@ -148,6 +154,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no async def async_handle_core_service(call: ha.ServiceCall) -> None: """Service handler for handling core services.""" + stop_handler: Callable[[ha.HomeAssistant, bool], Coroutine[Any, Any, None]] + if call.service in SHUTDOWN_SERVICES and recorder.async_migration_in_progress( hass ): @@ -161,8 +169,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_STOP: - # Track trask in hass.data. No need to cleanup, we're stopping. - hass.data["homeassistant_stop"] = asyncio.create_task(hass.async_stop()) + stop_handler = hass.data[DATA_STOP_HANDLER] + await stop_handler(hass, False) return errors = await conf_util.async_check_ha_config_file(hass) @@ -185,10 +193,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_RESTART: - # Track trask in hass.data. No need to cleanup, we're stopping. - hass.data["homeassistant_stop"] = asyncio.create_task( - hass.async_stop(RESTART_EXIT_CODE) - ) + stop_handler = hass.data[DATA_STOP_HANDLER] + await stop_handler(hass, True) async def async_handle_update_service(call: ha.ServiceCall) -> None: """Service handler for updating an entity.""" @@ -358,5 +364,22 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no exposed_entities = ExposedEntities(hass) await exposed_entities.async_initialize() hass.data[DATA_EXPOSED_ENTITIES] = exposed_entities + async_set_stop_handler(hass, _async_stop) return True + + +async def _async_stop(hass: ha.HomeAssistant, restart: bool): + """Stop home assistant.""" + exit_code = RESTART_EXIT_CODE if restart else 0 + # Track trask in hass.data. No need to cleanup, we're stopping. + hass.data["homeassistant_stop"] = asyncio.create_task(hass.async_stop(exit_code)) + + +@ha.callback +def async_set_stop_handler( + hass: ha.HomeAssistant, + stop_handler: Callable[[ha.HomeAssistant, bool], Coroutine[Any, Any, None]], +) -> None: + """Set function which is called by the stop and restart services.""" + hass.data[DATA_STOP_HANDLER] = stop_handler diff --git a/homeassistant/components/homeassistant/const.py b/homeassistant/components/homeassistant/const.py index f3bc95dd1ee..871ea5a0371 100644 --- a/homeassistant/components/homeassistant/const.py +++ b/homeassistant/components/homeassistant/const.py @@ -1,6 +1,12 @@ """Constants for the Homeassistant integration.""" +from typing import Final + import homeassistant.core as ha DOMAIN = ha.DOMAIN DATA_EXPOSED_ENTITIES = f"{DOMAIN}.exposed_entites" +DATA_STOP_HANDLER = f"{DOMAIN}.stop_handler" + +SERVICE_HOMEASSISTANT_STOP: Final = "stop" +SERVICE_HOMEASSISTANT_RESTART: Final = "restart" diff --git a/homeassistant/const.py b/homeassistant/const.py index 2d84a57afa4..d44ec25230f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1058,9 +1058,6 @@ COMPRESSED_STATE_LAST_CHANGED = "lc" COMPRESSED_STATE_LAST_UPDATED = "lu" # #### SERVICES #### -SERVICE_HOMEASSISTANT_STOP: Final = "stop" -SERVICE_HOMEASSISTANT_RESTART: Final = "restart" - SERVICE_TURN_ON: Final = "turn_on" SERVICE_TURN_OFF: Final = "turn_off" SERVICE_TOGGLE: Final = "toggle" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index c04a26638e6..99e1de6e763 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -692,6 +692,7 @@ async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Call core service and check the API calls behind that.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "hassio", {}) aioclient_mock.post("http://127.0.0.1/homeassistant/restart", json={"result": "ok"}) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 4c5643ae3ca..9048e03ea70 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -12,6 +12,8 @@ import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, SERVICE_CHECK_CONFIG, + SERVICE_HOMEASSISTANT_RESTART, + SERVICE_HOMEASSISTANT_STOP, SERVICE_RELOAD_ALL, SERVICE_RELOAD_CORE_CONFIG, SERVICE_RELOAD_CUSTOM_TEMPLATES, @@ -22,8 +24,6 @@ from homeassistant.const import ( ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, EVENT_CORE_CONFIG_UPDATE, - SERVICE_HOMEASSISTANT_RESTART, - SERVICE_HOMEASSISTANT_STOP, SERVICE_SAVE_PERSISTENT_STATES, SERVICE_TOGGLE, SERVICE_TURN_OFF, From c481fdb7d062bbf07d87dff9cc4baeef7d16faea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 23 Oct 2023 20:33:08 +0200 Subject: [PATCH 742/968] Rename safe mode to recovery mode (#102580) --- homeassistant/__main__.py | 6 +++-- homeassistant/bootstrap.py | 22 +++++++++---------- homeassistant/components/frontend/__init__.py | 6 ++--- homeassistant/components/http/__init__.py | 6 ++--- homeassistant/components/lovelace/__init__.py | 2 +- .../components/lovelace/dashboard.py | 10 ++++----- homeassistant/core.py | 6 ++--- homeassistant/helpers/check_config.py | 4 ++-- homeassistant/loader.py | 4 ++-- homeassistant/runner.py | 2 +- tests/components/frontend/test_init.py | 8 +++---- tests/components/http/test_init.py | 16 +++++++------- tests/components/lovelace/test_dashboard.py | 4 ++-- tests/helpers/test_check_config.py | 12 +++++----- tests/test_bootstrap.py | 14 ++++++------ tests/test_core.py | 4 ++-- tests/test_loader.py | 6 ++--- 17 files changed, 67 insertions(+), 65 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9e4afa018a6..9acf46dbac6 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -93,7 +93,9 @@ def get_arguments() -> argparse.Namespace: help="Directory that contains the Home Assistant configuration", ) parser.add_argument( - "--safe-mode", action="store_true", help="Start Home Assistant in safe mode" + "--recovery-mode", + action="store_true", + help="Start Home Assistant in recovery mode", ) parser.add_argument( "--debug", action="store_true", help="Start Home Assistant in debug mode" @@ -193,7 +195,7 @@ def main() -> int: log_no_color=args.log_no_color, skip_pip=args.skip_pip, skip_pip_packages=args.skip_pip_packages, - safe_mode=args.safe_mode, + recovery_mode=args.recovery_mode, debug=args.debug, open_ui=args.open_ui, ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 81ae4eb6e18..7f6c29d8105 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -137,14 +137,14 @@ async def async_setup_hass( config_dict = None basic_setup_success = False - if not (safe_mode := runtime_config.safe_mode): + if not (recovery_mode := runtime_config.recovery_mode): await hass.async_add_executor_job(conf_util.process_ha_config_upgrade, hass) try: config_dict = await conf_util.async_hass_config_yaml(hass) except HomeAssistantError as err: _LOGGER.error( - "Failed to parse configuration.yaml: %s. Activating safe mode", + "Failed to parse configuration.yaml: %s. Activating recovery mode", err, ) else: @@ -156,24 +156,24 @@ async def async_setup_hass( ) if config_dict is None: - safe_mode = True + recovery_mode = True elif not basic_setup_success: - _LOGGER.warning("Unable to set up core integrations. Activating safe mode") - safe_mode = True + _LOGGER.warning("Unable to set up core integrations. Activating recovery mode") + recovery_mode = True elif ( "frontend" in hass.data.get(DATA_SETUP, {}) and "frontend" not in hass.config.components ): - _LOGGER.warning("Detected that frontend did not load. Activating safe mode") + _LOGGER.warning("Detected that frontend did not load. Activating recovery mode") # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken with contextlib.suppress(asyncio.TimeoutError): async with hass.timeout.async_timeout(10): await hass.async_stop() - safe_mode = True + recovery_mode = True old_config = hass.config old_logging = hass.data.get(DATA_LOGGING) @@ -187,9 +187,9 @@ async def async_setup_hass( # Setup loader cache after the config dir has been set loader.async_setup(hass) - if safe_mode: - _LOGGER.info("Starting in safe mode") - hass.config.safe_mode = True + if recovery_mode: + _LOGGER.info("Starting in recovery mode") + hass.config.recovery_mode = True http_conf = (await http.async_get_last_config(hass)) or {} @@ -471,7 +471,7 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domains = {key.partition(" ")[0] for key in config if key != core.DOMAIN} # Add config entry domains - if not hass.config.safe_mode: + if not hass.config.recovery_mode: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 98b6f0331b5..e8a71d23adf 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -658,18 +658,18 @@ def websocket_get_themes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" - if hass.config.safe_mode: + if hass.config.recovery_mode: connection.send_message( websocket_api.result_message( msg["id"], { "themes": { - "safe_mode": { + "recovery_mode": { "primary-color": "#db4437", "accent-color": "#ffca28", } }, - "default_theme": "safe_mode", + "default_theme": "recovery_mode", }, ) ) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 409b78fb16a..122b7b79ce9 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -445,7 +445,7 @@ class HomeAssistantHTTP: context = ssl_util.server_context_modern() context.load_cert_chain(self.ssl_certificate, self.ssl_key) except OSError as error: - if not self.hass.config.safe_mode: + if not self.hass.config.recovery_mode: raise HomeAssistantError( f"Could not use SSL certificate from {self.ssl_certificate}:" f" {error}" @@ -465,7 +465,7 @@ class HomeAssistantHTTP: context = None else: _LOGGER.critical( - "Home Assistant is running in safe mode with an emergency self" + "Home Assistant is running in recovery mode with an emergency self" " signed ssl certificate because the configured SSL certificate was" " not usable" ) @@ -572,7 +572,7 @@ async def start_http_server_and_save_config( """Startup the http server and save the config.""" await server.start() - # If we are set up successful, we store the HTTP settings for safe mode. + # If we are set up successful, we store the HTTP settings for recovery mode. store: storage.Store[dict[str, Any]] = storage.Store( hass, STORAGE_VERSION, STORAGE_KEY ) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 1412aa085c8..2c425bec785 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -144,7 +144,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), } - if hass.config.safe_mode: + if hass.config.recovery_mode: return True async def storage_dashboard_changed(change_type, item_id, item): diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 054aaf9b24c..e1641451221 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -114,7 +114,7 @@ class LovelaceStorage(LovelaceConfig): async def async_load(self, force): """Load config.""" - if self.hass.config.safe_mode: + if self.hass.config.recovery_mode: raise ConfigNotFound if self._data is None: @@ -127,8 +127,8 @@ class LovelaceStorage(LovelaceConfig): async def async_save(self, config): """Save config.""" - if self.hass.config.safe_mode: - raise HomeAssistantError("Saving not supported in safe mode") + if self.hass.config.recovery_mode: + raise HomeAssistantError("Saving not supported in recovery mode") if self._data is None: await self._load() @@ -138,8 +138,8 @@ class LovelaceStorage(LovelaceConfig): async def async_delete(self): """Delete config.""" - if self.hass.config.safe_mode: - raise HomeAssistantError("Deleting not supported in safe mode") + if self.hass.config.recovery_mode: + raise HomeAssistantError("Deleting not supported in recovery mode") await self._store.async_remove() self._data = None diff --git a/homeassistant/core.py b/homeassistant/core.py index 2cc79e5bbb4..e495973440e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2129,8 +2129,8 @@ class Config: # Dictionary of Media folders that integrations may use self.media_dirs: dict[str, str] = {} - # If Home Assistant is running in safe mode - self.safe_mode: bool = False + # If Home Assistant is running in recovery mode + self.recovery_mode: bool = False # Use legacy template behavior self.legacy_templates: bool = False @@ -2208,7 +2208,7 @@ class Config: "allowlist_external_urls": self.allowlist_external_urls, "version": __version__, "config_source": self.config_source, - "safe_mode": self.safe_mode, + "recovery_mode": self.recovery_mode, "state": self.hass.state.value, "external_url": self.external_url, "internal_url": self.internal_url, diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 1e1cac050f1..3218c1e839b 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ async def async_check_ha_config_file( # noqa: C901 try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.safe_mode: + if not hass.config.recovery_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: @@ -216,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.safe_mode: + if not hass.config.recovery_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 6107150cebb..e4f36f11a36 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -188,7 +188,7 @@ async def _async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return list of custom integrations.""" - if hass.config.safe_mode: + if hass.config.recovery_mode: return {} try: @@ -1179,7 +1179,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: def _lookup_path(hass: HomeAssistant) -> list[str]: """Return the lookup paths for legacy lookups.""" - if hass.config.safe_mode: + if hass.config.recovery_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 10521f80135..ca658c154a2 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -43,7 +43,7 @@ class RuntimeConfig: config_dir: str skip_pip: bool = False skip_pip_packages: list[str] = dataclasses.field(default_factory=list) - safe_mode: bool = False + recovery_mode: bool = False verbose: bool = False diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 721f6416154..f0c433f2e96 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -177,14 +177,14 @@ async def test_themes_api(hass: HomeAssistant, themes_ws_client) -> None: assert msg["result"]["default_dark_theme"] is None assert msg["result"]["themes"] == MOCK_THEMES - # safe mode - hass.config.safe_mode = True + # recovery mode + hass.config.recovery_mode = True await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() - assert msg["result"]["default_theme"] == "safe_mode" + assert msg["result"]["default_theme"] == "recovery_mode" assert msg["result"]["themes"] == { - "safe_mode": {"primary-color": "#db4437", "accent-color": "#ffca28"} + "recovery_mode": {"primary-color": "#db4437", "accent-color": "#ffca28"} } diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 3fc8d7689d6..5a5bffe6748 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -289,7 +289,7 @@ async def test_emergency_ssl_certificate_when_invalid( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True assert ( await async_setup_component( hass, @@ -304,17 +304,17 @@ async def test_emergency_ssl_certificate_when_invalid( await hass.async_start() await hass.async_block_till_done() assert ( - "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" in caplog.text ) assert hass.http.site is not None -async def test_emergency_ssl_certificate_not_used_when_not_safe_mode( +async def test_emergency_ssl_certificate_not_used_when_not_recovery_mode( hass: HomeAssistant, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: - """Test an emergency cert is only used in safe mode.""" + """Test an emergency cert is only used in recovery mode.""" cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path @@ -338,7 +338,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.get_url", side_effect=NoURLAvailableError @@ -358,7 +358,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( assert len(mock_get_url.mock_calls) == 1 assert ( - "Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" + "Home Assistant is running in recovery mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable" in caplog.text ) @@ -373,7 +373,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError @@ -410,7 +410,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( cert_path, key_path = await hass.async_add_executor_job( _setup_broken_ssl_pem_files, tmp_path ) - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 8663ec0fc11..05bc7f372b8 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -48,8 +48,8 @@ async def test_lovelace_from_storage( assert response["result"] == {"yo": "hello"} - # Test with safe mode - hass.config.safe_mode = True + # Test with recovery mode + hass.config.recovery_mode = True await client.send_json({"id": 8, "type": "lovelace/config"}) response = await client.receive_json() assert not response["success"] diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 3b9b3cf6558..6af03136760 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -112,11 +112,11 @@ async def test_component_requirement_not_found(hass: HomeAssistant) -> None: assert not res.errors -async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: - """Test no errors if component not found in safe mode.""" +async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in recovery mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) log_ha_config(res) @@ -145,11 +145,11 @@ async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: assert not res.errors -async def test_platform_not_found_safe_mode(hass: HomeAssistant) -> None: - """Test no errors if platform not found in safe_mode.""" +async def test_platform_not_found_recovery_mode(hass: HomeAssistant) -> None: + """Test no errors if platform not found in recovery_mode.""" # Make sure they don't exist files = {YAML_CONFIG_FILE: BASE_CONFIG + "light:\n platform: beer"} - hass.config.safe_mode = True + hass.config.recovery_mode = True with patch("os.path.isfile", return_value=True), patch_yaml_files(files): res = await async_check_ha_config_file(hass) log_ha_config(res) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index ea9e04ac993..6938acb9cc9 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -488,7 +488,7 @@ async def test_setup_hass( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) @@ -547,7 +547,7 @@ async def test_setup_hass_takes_longer_than_log_slow_startup( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) @@ -574,7 +574,7 @@ async def test_setup_hass_invalid_yaml( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) @@ -602,7 +602,7 @@ async def test_setup_hass_config_dir_nonexistent( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) is None @@ -630,7 +630,7 @@ async def test_setup_hass_safe_mode( log_file="", log_no_color=False, skip_pip=True, - safe_mode=True, + recovery_mode=True, ), ) @@ -661,7 +661,7 @@ async def test_setup_hass_invalid_core_config( log_file="", log_no_color=False, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) @@ -704,7 +704,7 @@ async def test_setup_safe_mode_if_no_frontend( log_file=log_file, log_no_color=log_no_color, skip_pip=True, - safe_mode=False, + recovery_mode=False, ), ) diff --git a/tests/test_core.py b/tests/test_core.py index ed6823d2bd1..cd855ab2c73 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1449,7 +1449,7 @@ async def test_config_defaults() -> None: assert config.allowlist_external_dirs == set() assert config.allowlist_external_urls == set() assert config.media_dirs == {} - assert config.safe_mode is False + assert config.recovery_mode is False assert config.legacy_templates is False assert config.currency == "EUR" assert config.country is None @@ -1487,7 +1487,7 @@ async def test_config_as_dict() -> None: "allowlist_external_urls": set(), "version": __version__, "config_source": ha.ConfigSource.DEFAULT, - "safe_mode": False, + "recovery_mode": False, "state": "RUNNING", "external_url": None, "internal_url": None, diff --git a/tests/test_loader.py b/tests/test_loader.py index 3c95111db3a..7959ddb4684 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -690,9 +690,9 @@ async def test_get_mqtt(hass: HomeAssistant) -> None: assert mqtt["test_2"] == ["test_2/discovery"] -async def test_get_custom_components_safe_mode(hass: HomeAssistant) -> None: - """Test that we get empty custom components in safe mode.""" - hass.config.safe_mode = True +async def test_get_custom_components_recovery_mode(hass: HomeAssistant) -> None: + """Test that we get empty custom components in recovery mode.""" + hass.config.recovery_mode = True assert await loader.async_get_custom_components(hass) == {} From a78e3f7b0f1e1424de84c00297f4d0da7202d0a2 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Mon, 23 Oct 2023 13:34:32 -0500 Subject: [PATCH 743/968] Delay import of webrtc to avoid blocking start up if package is missing (#102594) * Delay import of webrtc to avoid blocking start up if package is missing * Update homeassistant/components/assist_pipeline/pipeline.py Co-authored-by: Paulus Schoutsen * Update homeassistant/components/assist_pipeline/vad.py Co-authored-by: Paulus Schoutsen --------- Co-authored-by: Paulus Schoutsen --- homeassistant/components/assist_pipeline/pipeline.py | 12 ++++++++++-- homeassistant/components/assist_pipeline/vad.py | 8 ++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index bb34a223af6..1e1c0b6f495 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -12,11 +12,13 @@ from pathlib import Path from queue import Queue from threading import Thread import time -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import wave import voluptuous as vol -from webrtc_noise_gain import AudioProcessor + +if TYPE_CHECKING: + from webrtc_noise_gain import AudioProcessor from homeassistant.components import ( conversation, @@ -522,6 +524,12 @@ class PipelineRun: # Initialize with audio settings self.audio_processor_buffer = AudioBuffer(AUDIO_PROCESSOR_BYTES) if self.audio_settings.needs_processor: + # Delay import of webrtc so HA start up is not crashing + # on older architectures (armhf). + # + # pylint: disable=import-outside-toplevel + from webrtc_noise_gain import AudioProcessor + self.audio_processor = AudioProcessor( self.audio_settings.auto_gain_dbfs, self.audio_settings.noise_suppression_level, diff --git a/homeassistant/components/assist_pipeline/vad.py b/homeassistant/components/assist_pipeline/vad.py index 30fad1c80d6..9cc5fe9dfc6 100644 --- a/homeassistant/components/assist_pipeline/vad.py +++ b/homeassistant/components/assist_pipeline/vad.py @@ -7,8 +7,6 @@ from dataclasses import dataclass from enum import StrEnum from typing import Final, cast -from webrtc_noise_gain import AudioProcessor - _SAMPLE_RATE: Final = 16000 # Hz _SAMPLE_WIDTH: Final = 2 # bytes @@ -51,6 +49,12 @@ class WebRtcVad(VoiceActivityDetector): def __init__(self) -> None: """Initialize webrtcvad.""" + # Delay import of webrtc so HA start up is not crashing + # on older architectures (armhf). + # + # pylint: disable=import-outside-toplevel + from webrtc_noise_gain import AudioProcessor + # Just VAD: no noise suppression or auto gain self._audio_processor = AudioProcessor(0, 0) From 4c8a919ca3142424ff0f878109f3b868eaaa8cd4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Oct 2023 15:46:05 -0500 Subject: [PATCH 744/968] Bump aioesphomeapi to 18.0.11 (#102603) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index ae52af971ed..4cade907899 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.10", + "aioesphomeapi==18.0.11", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index c886ab7e2b3..abe86ea184f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.10 +aioesphomeapi==18.0.11 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e5a5492059d..03fe4b9d577 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.10 +aioesphomeapi==18.0.11 # homeassistant.components.flo aioflo==2021.11.0 From fa1df7e334f5d84734e8fe8f65ff985ba91418c0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 23 Oct 2023 15:48:19 -0500 Subject: [PATCH 745/968] Bump pyatv to 0.14.3 (#102196) --- homeassistant/components/apple_tv/manifest.json | 2 +- homeassistant/components/apple_tv/media_player.py | 8 ++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index a22687c0fb5..1f7ac45372e 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.13.4"], + "requirements": ["pyatv==0.14.3"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index a70a30656f2..dd1f554919e 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -371,11 +371,15 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): @property def repeat(self) -> RepeatMode | None: """Return current repeat mode.""" - if self._playing and self._is_feature_available(FeatureName.Repeat): + if ( + self._playing + and self._is_feature_available(FeatureName.Repeat) + and (repeat := self._playing.repeat) + ): return { RepeatState.Track: RepeatMode.ONE, RepeatState.All: RepeatMode.ALL, - }.get(self._playing.repeat, RepeatMode.OFF) + }.get(repeat, RepeatMode.OFF) return None @property diff --git a/requirements_all.txt b/requirements_all.txt index abe86ea184f..fa334ab6ea6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1598,7 +1598,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.4 +pyatv==0.14.3 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 03fe4b9d577..c28a50a81ee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1216,7 +1216,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.13.4 +pyatv==0.14.3 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From 5d430f53cd87143f724bc0a4e8f960e4d6c51252 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 23 Oct 2023 13:53:00 -0700 Subject: [PATCH 746/968] Add todo component (#100019) --- CODEOWNERS | 2 + .../components/shopping_list/__init__.py | 121 ++- .../components/shopping_list/strings.json | 7 + .../components/shopping_list/todo.py | 106 +++ homeassistant/components/todo/__init__.py | 262 +++++++ homeassistant/components/todo/const.py | 24 + homeassistant/components/todo/manifest.json | 9 + homeassistant/components/todo/services.yaml | 55 ++ homeassistant/components/todo/strings.json | 64 ++ homeassistant/const.py | 1 + homeassistant/helpers/selector.py | 2 + pylint/plugins/hass_enforce_type_hints.py | 48 ++ tests/components/shopping_list/conftest.py | 14 +- tests/components/shopping_list/test_todo.py | 493 ++++++++++++ tests/components/todo/__init__.py | 1 + tests/components/todo/test_init.py | 730 ++++++++++++++++++ 16 files changed, 1908 insertions(+), 31 deletions(-) create mode 100644 homeassistant/components/shopping_list/todo.py create mode 100644 homeassistant/components/todo/__init__.py create mode 100644 homeassistant/components/todo/const.py create mode 100644 homeassistant/components/todo/manifest.json create mode 100644 homeassistant/components/todo/services.yaml create mode 100644 homeassistant/components/todo/strings.json create mode 100644 tests/components/shopping_list/test_todo.py create mode 100644 tests/components/todo/__init__.py create mode 100644 tests/components/todo/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index ff485ff6d92..87ac2cebfcb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1303,6 +1303,8 @@ build.json @home-assistant/supervisor /homeassistant/components/time_date/ @fabaff /tests/components/time_date/ @fabaff /homeassistant/components/tmb/ @alemuro +/homeassistant/components/todo/ @home-assistant/core +/tests/components/todo/ @home-assistant/core /homeassistant/components/todoist/ @boralyl /tests/components/todoist/ @boralyl /homeassistant/components/tolo/ @MatthiasLohr diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 3dc26fe007a..f2de59b10af 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,21 +1,22 @@ """Support to manage a shopping list.""" +from collections.abc import Callable from http import HTTPStatus import logging -from typing import Any +from typing import Any, cast import uuid import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import frontend, http, websocket_api +from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType -from homeassistant.util.json import JsonArrayType, load_json_array +from homeassistant.util.json import JsonValueType, load_json_array from .const import ( ATTR_REVERSE, @@ -32,6 +33,8 @@ from .const import ( SERVICE_SORT, ) +PLATFORMS = [Platform.TODO] + ATTR_COMPLETE = "complete" _LOGGER = logging.getLogger(__name__) @@ -169,10 +172,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.http.register_view(UpdateShoppingListItemView) hass.http.register_view(ClearCompletedItemsView) - frontend.async_register_built_in_panel( - hass, "shopping-list", "shopping_list", "mdi:cart" - ) - websocket_api.async_register_command(hass, websocket_handle_items) websocket_api.async_register_command(hass, websocket_handle_add) websocket_api.async_register_command(hass, websocket_handle_remove) @@ -180,6 +179,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b websocket_api.async_register_command(hass, websocket_handle_clear) websocket_api.async_register_command(hass, websocket_handle_reorder) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True @@ -193,13 +194,15 @@ class ShoppingData: def __init__(self, hass: HomeAssistant) -> None: """Initialize the shopping list.""" self.hass = hass - self.items: JsonArrayType = [] + self.items: list[dict[str, JsonValueType]] = [] + self._listeners: list[Callable[[], None]] = [] - async def async_add(self, name, context=None): + async def async_add(self, name, complete=False, context=None): """Add a shopping list item.""" - item = {"name": name, "id": uuid.uuid4().hex, "complete": False} + item = {"name": name, "id": uuid.uuid4().hex, "complete": complete} self.items.append(item) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "add", "item": item}, @@ -207,21 +210,43 @@ class ShoppingData: ) return item - async def async_remove(self, item_id, context=None): + async def async_remove( + self, item_id: str, context=None + ) -> dict[str, JsonValueType] | None: """Remove a shopping list item.""" - item = next((itm for itm in self.items if itm["id"] == item_id), None) - - if item is None: - raise NoMatchingShoppingListItem - - self.items.remove(item) - await self.hass.async_add_executor_job(self.save) - self.hass.bus.async_fire( - EVENT_SHOPPING_LIST_UPDATED, - {"action": "remove", "item": item}, - context=context, + removed = await self.async_remove_items( + item_ids=set({item_id}), context=context ) - return item + return next(iter(removed), None) + + async def async_remove_items( + self, item_ids: set[str], context=None + ) -> list[dict[str, JsonValueType]]: + """Remove a shopping list item.""" + items_dict: dict[str, dict[str, JsonValueType]] = {} + for itm in self.items: + item_id = cast(str, itm["id"]) + items_dict[item_id] = itm + removed = [] + for item_id in item_ids: + _LOGGER.debug( + "Removing %s", + ) + if not (item := items_dict.pop(item_id, None)): + raise NoMatchingShoppingListItem( + "Item '{item_id}' not found in shopping list" + ) + removed.append(item) + self.items = list(items_dict.values()) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + for item in removed: + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "remove", "item": item}, + context=context, + ) + return removed async def async_update(self, item_id, info, context=None): """Update a shopping list item.""" @@ -233,6 +258,7 @@ class ShoppingData: info = ITEM_UPDATE_SCHEMA(info) item.update(info) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "update", "item": item}, @@ -244,6 +270,7 @@ class ShoppingData: """Clear completed items.""" self.items = [itm for itm in self.items if not itm["complete"]] await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "clear"}, @@ -255,6 +282,7 @@ class ShoppingData: for item in self.items: item.update(info) await self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "update_list"}, @@ -287,16 +315,36 @@ class ShoppingData: new_items.append(all_items_mapping[key]) self.items = new_items self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "reorder"}, context=context, ) + async def async_move_item(self, uid: str, pos: int) -> None: + """Re-order a shopping list item.""" + found_item: dict[str, Any] | None = None + for idx, itm in enumerate(self.items): + if cast(str, itm["id"]) == uid: + found_item = itm + self.items.pop(idx) + break + if not found_item: + raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") + self.items.insert(pos, found_item) + await self.hass.async_add_executor_job(self.save) + self._async_notify() + self.hass.bus.async_fire( + EVENT_SHOPPING_LIST_UPDATED, + {"action": "reorder"}, + ) + async def async_sort(self, reverse=False, context=None): """Sort items by name.""" self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) self.hass.async_add_executor_job(self.save) + self._async_notify() self.hass.bus.async_fire( EVENT_SHOPPING_LIST_UPDATED, {"action": "sorted"}, @@ -306,9 +354,12 @@ class ShoppingData: async def async_load(self) -> None: """Load items.""" - def load() -> JsonArrayType: + def load() -> list[dict[str, JsonValueType]]: """Load the items synchronously.""" - return load_json_array(self.hass.config.path(PERSISTENCE)) + return cast( + list[dict[str, JsonValueType]], + load_json_array(self.hass.config.path(PERSISTENCE)), + ) self.items = await self.hass.async_add_executor_job(load) @@ -316,6 +367,20 @@ class ShoppingData: """Save the items.""" save_json(self.hass.config.path(PERSISTENCE), self.items) + def async_add_listener(self, cb: Callable[[], None]) -> Callable[[], None]: + """Add a listener to notify when data is updated.""" + + def unsub(): + self._listeners.remove(cb) + + self._listeners.append(cb) + return unsub + + def _async_notify(self) -> None: + """Notify all listeners that data has been updated.""" + for listener in self._listeners: + listener() + class ShoppingListView(http.HomeAssistantView): """View to retrieve shopping list content.""" @@ -397,7 +462,9 @@ async def websocket_handle_add( msg: dict[str, Any], ) -> None: """Handle adding item to shopping_list.""" - item = await hass.data[DOMAIN].async_add(msg["name"], connection.context(msg)) + item = await hass.data[DOMAIN].async_add( + msg["name"], context=connection.context(msg) + ) connection.send_message(websocket_api.result_message(msg["id"], item)) diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json index ddac4713fac..c184a1d2227 100644 --- a/homeassistant/components/shopping_list/strings.json +++ b/homeassistant/components/shopping_list/strings.json @@ -74,5 +74,12 @@ } } } + }, + "entity": { + "todo": { + "shopping_list": { + "name": "[%key:component::shopping_list::title%]" + } + } } } diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py new file mode 100644 index 00000000000..fd83f138392 --- /dev/null +++ b/homeassistant/components/shopping_list/todo.py @@ -0,0 +1,106 @@ +"""A shopping list todo platform.""" + +from typing import Any, cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import NoMatchingShoppingListItem, ShoppingData +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the shopping_list todo platform.""" + shopping_data = hass.data[DOMAIN] + entity = ShoppingTodoListEntity(shopping_data, unique_id=config_entry.entry_id) + async_add_entities([entity], True) + + +class ShoppingTodoListEntity(TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_translation_key = "shopping_list" + _attr_should_poll = False + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + + def __init__(self, data: ShoppingData, unique_id: str) -> None: + """Initialize ShoppingTodoListEntity.""" + self._attr_unique_id = unique_id + self._data = data + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self._data.async_add( + item.summary, complete=(item.status == TodoItemStatus.COMPLETED) + ) + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + data: dict[str, Any] = {} + if item.summary: + data["name"] = item.summary + if item.status: + data["complete"] = item.status == TodoItemStatus.COMPLETED + try: + await self._data.async_update(item.uid, data) + except NoMatchingShoppingListItem as err: + raise HomeAssistantError( + f"Shopping list item '{item.uid}' was not found" + ) from err + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Add an item to the To-do list.""" + await self._data.async_remove_items(set(uids)) + + async def async_move_todo_item(self, uid: str, pos: int) -> None: + """Re-order an item to the To-do list.""" + + try: + await self._data.async_move_item(uid, pos) + except NoMatchingShoppingListItem as err: + raise HomeAssistantError( + f"Shopping list item '{uid}' could not be re-ordered" + ) from err + + async def async_added_to_hass(self) -> None: + """Entity has been added to hass.""" + # Shopping list integration doesn't currently support config entry unload + # so this code may not be used in practice, however it is here in case + # this changes in the future. + self.async_on_remove(self._data.async_add_listener(self.async_write_ha_state)) + + @property + def todo_items(self) -> list[TodoItem]: + """Get items in the To-do list.""" + results = [] + for item in self._data.items: + if cast(bool, item["complete"]): + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + results.append( + TodoItem( + summary=cast(str, item["name"]), + uid=cast(str, item["id"]), + status=status, + ) + ) + return results diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py new file mode 100644 index 00000000000..a6660b0231a --- /dev/null +++ b/homeassistant/components/todo/__init__.py @@ -0,0 +1,262 @@ +"""The todo integration.""" + +import dataclasses +import datetime +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components import frontend, websocket_api +from homeassistant.components.websocket_api import ERR_NOT_FOUND, ERR_NOT_SUPPORTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ENTITY_ID +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + PLATFORM_SCHEMA, + PLATFORM_SCHEMA_BASE, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN, TodoItemStatus, TodoListEntityFeature + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(seconds=60) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Todo entities.""" + component = hass.data[DOMAIN] = EntityComponent[TodoListEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + + frontend.async_register_built_in_panel(hass, "todo", "todo", "mdi:clipboard-list") + + websocket_api.async_register_command(hass, websocket_handle_todo_item_list) + websocket_api.async_register_command(hass, websocket_handle_todo_item_move) + + component.async_register_entity_service( + "create_item", + { + vol.Required("summary"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("status", default=TodoItemStatus.NEEDS_ACTION): vol.In( + {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} + ), + }, + _async_create_todo_item, + required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], + ) + component.async_register_entity_service( + "update_item", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("uid"): cv.string, + vol.Optional("summary"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("status"): vol.In( + {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} + ), + } + ), + cv.has_at_least_one_key("uid", "summary"), + ), + _async_update_todo_item, + required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], + ) + component.async_register_entity_service( + "delete_item", + vol.All( + cv.make_entity_service_schema( + { + vol.Optional("uid"): vol.All(cv.ensure_list, [cv.string]), + vol.Optional("summary"): vol.All(cv.ensure_list, [cv.string]), + } + ), + cv.has_at_least_one_key("uid", "summary"), + ), + _async_delete_todo_items, + required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], + ) + + await component.async_setup(config) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + return await component.async_setup_entry(entry) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclasses.dataclass +class TodoItem: + """A To-do item in a To-do list.""" + + summary: str | None = None + """The summary that represents the item.""" + + uid: str | None = None + """A unique identifier for the To-do item.""" + + status: TodoItemStatus | None = None + """A status or confirmation of the To-do item.""" + + @classmethod + def from_dict(cls, obj: dict[str, Any]) -> "TodoItem": + """Create a To-do Item from a dictionary parsed by schema validators.""" + return cls( + summary=obj.get("summary"), status=obj.get("status"), uid=obj.get("uid") + ) + + +class TodoListEntity(Entity): + """An entity that represents a To-do list.""" + + _attr_todo_items: list[TodoItem] | None = None + + @property + def state(self) -> int | None: + """Return the entity state as the count of incomplete items.""" + items = self.todo_items + if items is None: + return None + return sum([item.status == TodoItemStatus.NEEDS_ACTION for item in items]) + + @property + def todo_items(self) -> list[TodoItem] | None: + """Return the To-do items in the To-do list.""" + return self._attr_todo_items + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + raise NotImplementedError() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item in the To-do list.""" + raise NotImplementedError() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete an item in the To-do list.""" + raise NotImplementedError() + + async def async_move_todo_item(self, uid: str, pos: int) -> None: + """Move an item in the To-do list.""" + raise NotImplementedError() + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/list", + vol.Required("entity_id"): cv.entity_id, + } +) +@websocket_api.async_response +async def websocket_handle_todo_item_list( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle the list of To-do items in a To-do- list.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + if ( + not (entity_id := msg[CONF_ENTITY_ID]) + or not (entity := component.get_entity(entity_id)) + or not isinstance(entity, TodoListEntity) + ): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") + return + + items: list[TodoItem] = entity.todo_items or [] + connection.send_message( + websocket_api.result_message( + msg["id"], {"items": [dataclasses.asdict(item) for item in items]} + ) + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "todo/item/move", + vol.Required("entity_id"): cv.entity_id, + vol.Required("uid"): cv.string, + vol.Optional("pos", default=0): cv.positive_int, + } +) +@websocket_api.async_response +async def websocket_handle_todo_item_move( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] +) -> None: + """Handle move of a To-do item within a To-do list.""" + component: EntityComponent[TodoListEntity] = hass.data[DOMAIN] + if not (entity := component.get_entity(msg["entity_id"])): + connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") + return + + if ( + not entity.supported_features + or not entity.supported_features & TodoListEntityFeature.MOVE_TODO_ITEM + ): + connection.send_message( + websocket_api.error_message( + msg["id"], + ERR_NOT_SUPPORTED, + "To-do list does not support To-do item reordering", + ) + ) + return + + try: + await entity.async_move_todo_item(uid=msg["uid"], pos=msg["pos"]) + except HomeAssistantError as ex: + connection.send_error(msg["id"], "failed", str(ex)) + else: + connection.send_result(msg["id"]) + + +def _find_by_summary(summary: str, items: list[TodoItem] | None) -> TodoItem | None: + """Find a To-do List item by summary name.""" + for item in items or (): + if item.summary == summary: + return item + return None + + +async def _async_create_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: + """Add an item to the To-do list.""" + await entity.async_create_todo_item(item=TodoItem.from_dict(call.data)) + + +async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: + """Update an item in the To-do list.""" + item = TodoItem.from_dict(call.data) + if not item.uid: + found = _find_by_summary(call.data["summary"], entity.todo_items) + if not found: + raise ValueError(f"Unable to find To-do item with summary '{item.summary}'") + item.uid = found.uid + + await entity.async_update_todo_item(item=item) + + +async def _async_delete_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: + """Delete an item in the To-do list.""" + uids = call.data.get("uid", []) + if not uids: + summaries = call.data.get("summary", []) + for summary in summaries: + item = _find_by_summary(summary, entity.todo_items) + if not item: + raise ValueError(f"Unable to find To-do item with summary '{summary}") + uids.append(item.uid) + await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/const.py b/homeassistant/components/todo/const.py new file mode 100644 index 00000000000..5a8a6e54e8f --- /dev/null +++ b/homeassistant/components/todo/const.py @@ -0,0 +1,24 @@ +"""Constants for the To-do integration.""" + +from enum import IntFlag, StrEnum + +DOMAIN = "todo" + + +class TodoListEntityFeature(IntFlag): + """Supported features of the To-do List entity.""" + + CREATE_TODO_ITEM = 1 + DELETE_TODO_ITEM = 2 + UPDATE_TODO_ITEM = 4 + MOVE_TODO_ITEM = 8 + + +class TodoItemStatus(StrEnum): + """Status or confirmation of a To-do List Item. + + This is a subset of the statuses supported in rfc5545. + """ + + NEEDS_ACTION = "needs_action" + COMPLETED = "completed" diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json new file mode 100644 index 00000000000..2edf3309e32 --- /dev/null +++ b/homeassistant/components/todo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "todo", + "name": "To-do", + "codeowners": ["@home-assistant/core"], + "dependencies": ["http"], + "documentation": "https://www.home-assistant.io/integrations/todo", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml new file mode 100644 index 00000000000..cf5f3da2b3a --- /dev/null +++ b/homeassistant/components/todo/services.yaml @@ -0,0 +1,55 @@ +create_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.CREATE_TODO_ITEM + fields: + summary: + required: true + example: "Submit Income Tax Return" + selector: + text: + status: + example: "needs_action" + selector: + select: + translation_key: status + options: + - needs_action + - completed +update_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.UPDATE_TODO_ITEM + fields: + uid: + selector: + text: + summary: + example: "Submit Income Tax Return" + selector: + text: + status: + example: "needs_action" + selector: + select: + translation_key: status + options: + - needs_action + - completed +delete_item: + target: + entity: + domain: todo + supported_features: + - todo.TodoListEntityFeature.DELETE_TODO_ITEM + fields: + uid: + selector: + object: + summary: + selector: + object: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json new file mode 100644 index 00000000000..4a5a33e94e5 --- /dev/null +++ b/homeassistant/components/todo/strings.json @@ -0,0 +1,64 @@ +{ + "title": "To-do List", + "entity_component": { + "_": { + "name": "[%key:component::todo::title%]" + } + }, + "services": { + "create_item": { + "name": "Create To-do List Item", + "description": "Add a new To-do List Item.", + "fields": { + "summary": { + "name": "Summary", + "description": "The short summary that represents the To-do item." + }, + "status": { + "name": "Status", + "description": "A status or confirmation of the To-do item." + } + } + }, + "update_item": { + "name": "Update To-do List Item", + "description": "Update an existing To-do List Item based on either its Unique Id or Summary.", + "fields": { + "uid": { + "name": "To-do Item Unique Id", + "description": "Unique Identifier for the To-do List Item." + }, + "summary": { + "name": "Summary", + "description": "The short summary that represents the To-do item." + }, + "status": { + "name": "Status", + "description": "A status or confirmation of the To-do item." + } + } + }, + "delete_item": { + "name": "Delete a To-do List Item", + "description": "Delete an existing To-do List Item either by its Unique Id or Summary.", + "fields": { + "uid": { + "name": "To-do Item Unique Ids", + "description": "Unique Identifiers for the To-do List Items." + }, + "summary": { + "name": "Summary", + "description": "The short summary that represents the To-do item." + } + } + } + }, + "selector": { + "status": { + "options": { + "needs_action": "Needs Action", + "completed": "Completed" + } + } + } +} diff --git a/homeassistant/const.py b/homeassistant/const.py index d44ec25230f..77c5582464e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -55,6 +55,7 @@ class Platform(StrEnum): SWITCH = "switch" TEXT = "text" TIME = "time" + TODO = "todo" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 1dba926a9af..51a54b3988f 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -99,6 +99,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.remote import RemoteEntityFeature from homeassistant.components.siren import SirenEntityFeature + from homeassistant.components.todo import TodoListEntityFeature from homeassistant.components.update import UpdateEntityFeature from homeassistant.components.vacuum import VacuumEntityFeature from homeassistant.components.water_heater import WaterHeaterEntityFeature @@ -118,6 +119,7 @@ def _entity_features() -> dict[str, type[IntFlag]]: "MediaPlayerEntityFeature": MediaPlayerEntityFeature, "RemoteEntityFeature": RemoteEntityFeature, "SirenEntityFeature": SirenEntityFeature, + "TodoListEntityFeature": TodoListEntityFeature, "UpdateEntityFeature": UpdateEntityFeature, "VacuumEntityFeature": VacuumEntityFeature, "WaterHeaterEntityFeature": WaterHeaterEntityFeature, diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index d3546dc7939..845b70b72ba 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2428,6 +2428,54 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "todo": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="RestoreEntity", + matches=_RESTORE_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="TodoListEntity", + matches=[ + TypeHintMatch( + function_name="todo_items", + return_type=["list[TodoItem]", None], + ), + TypeHintMatch( + function_name="async_create_todo_item", + arg_types={ + 1: "TodoItem", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_update_todo_item", + arg_types={ + 1: "TodoItem", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_delete_todo_items", + arg_types={ + 1: "list[str]", + }, + return_type="None", + ), + TypeHintMatch( + function_name="async_move_todo_item", + arg_types={ + 1: "str", + 2: "int", + }, + return_type="None", + ), + ], + ), + ], "tts": [ ClassTypeHintMatch( base_class="Provider", diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index 596a8c87cd3..aec55362d0b 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.shopping_list import intent as sl_intent +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -18,12 +19,17 @@ def mock_shopping_list_io(): @pytest.fixture -async def sl_setup(hass): +def mock_config_entry() -> MockConfigEntry: + """Config Entry fixture.""" + return MockConfigEntry(domain="shopping_list") + + +@pytest.fixture +async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry): """Set up the shopping list.""" - entry = MockConfigEntry(domain="shopping_list") - entry.add_to_hass(hass) + mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) await sl_intent.async_setup_intents(hass) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py new file mode 100644 index 00000000000..15f1e50bdb9 --- /dev/null +++ b/tests/components/shopping_list/test_todo.py @@ -0,0 +1,493 @@ +"""Test shopping list todo platform.""" + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.typing import WebSocketGenerator + +TEST_ENTITY = "todo.shopping_list" + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next() -> int: + nonlocal id + id += 1 + return id + + return next + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": TEST_ENTITY, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture +async def ws_move_item( + hass_ws_client: WebSocketGenerator, + ws_req_id: Callable[[], int], +) -> Callable[[str, int | None], Awaitable[None]]: + """Fixture to move an item in the todo list.""" + + async def move(uid: str, pos: int | None) -> dict[str, Any]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": uid, + } + if pos is not None: + data["pos"] = pos + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == id + return resp + + return move + + +async def test_get_items( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test creating a shopping list item with the WS API and verifying with To-do API.""" + client = await hass_ws_client(hass) + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Native shopping list websocket + await client.send_json( + {"id": ws_req_id(), "type": "shopping_list/items/add", "name": "soda"} + ) + msg = await client.receive_json() + assert msg["success"] is True + data = msg["result"] + assert data["name"] == "soda" + assert data["complete"] is False + + # Fetch items using To-do platform + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_create_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test creating shopping_list item and listing it.""" + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + { + "summary": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch items using To-do platform + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Add a completed item + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "paper", "status": "completed"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 2 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + assert items[1]["summary"] == "paper" + assert items[1]["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_delete_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting a todo item.""" + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "soda", "status": "needs_action"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "soda" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + { + "uid": [items[0]["uid"]], + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_bulk_delete( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting a todo item.""" + + for _i in range(0, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + { + "summary": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 5 + uids = [item["uid"] for item in items] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "5" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + { + "uid": uids, + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + { + "summary": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + **item, + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_partial_update_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item with partial information.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + { + "summary": "soda", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed without changing the summary + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "uid": item["uid"], + "status": "completed", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + # Change the summary without changing the status + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "uid": item["uid"], + "summary": "other summary", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is changed and still marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "other summary" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_invalid_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item that does not exist.""" + + with pytest.raises(HomeAssistantError, match="was not found"): + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + { + "uid": "invalid-uid", + "summary": "Example task", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("src_idx", "dst_idx", "expected_items"), + [ + # Move any item to the front of the list + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (1, 0, ["item 2", "item 1", "item 3", "item 4"]), + (2, 0, ["item 3", "item 1", "item 2", "item 4"]), + (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + # Move items right + (0, 1, ["item 2", "item 1", "item 3", "item 4"]), + (0, 2, ["item 2", "item 3", "item 1", "item 4"]), + (0, 3, ["item 2", "item 3", "item 4", "item 1"]), + (1, 2, ["item 1", "item 3", "item 2", "item 4"]), + (1, 3, ["item 1", "item 3", "item 4", "item 2"]), + # Move items left + (2, 1, ["item 1", "item 3", "item 2", "item 4"]), + (3, 1, ["item 1", "item 4", "item 2", "item 3"]), + (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + # No-ops + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 3, ["item 1", "item 2", "item 3", "item 4"]), + (3, 4, ["item 1", "item 2", "item 3", "item 4"]), + ], +) +async def test_move_item( + hass: HomeAssistant, + sl_setup: None, + ws_req_id: Callable[[], int], + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], + src_idx: int, + dst_idx: int | None, + expected_items: list[str], +) -> None: + """Test moving a todo item within the list.""" + + for i in range(1, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + { + "summary": f"item {i}", + }, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 4 + uids = [item["uid"] for item in items] + summaries = [item["summary"] for item in items] + assert summaries == ["item 1", "item 2", "item 3", "item 4"] + + resp = await ws_move_item(uids[src_idx], dst_idx) + assert resp.get("success") + + items = await ws_get_items() + assert len(items) == 4 + summaries = [item["summary"] for item in items] + assert summaries == expected_items + + +async def test_move_invalid_item( + hass: HomeAssistant, + sl_setup: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], +) -> None: + """Test moving an item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + + resp = await ws_move_item("unknown", 0) + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "could not be re-ordered" in resp.get("error", {}).get("message") diff --git a/tests/components/todo/__init__.py b/tests/components/todo/__init__.py new file mode 100644 index 00000000000..dfee74599cd --- /dev/null +++ b/tests/components/todo/__init__.py @@ -0,0 +1 @@ +"""Tests for the To-do integration.""" diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py new file mode 100644 index 00000000000..833a4ea266b --- /dev/null +++ b/tests/components/todo/test_init.py @@ -0,0 +1,730 @@ +"""Tests for the todo integration.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import AsyncMock + +import pytest +import voluptuous as vol + +from homeassistant.components.todo import ( + DOMAIN, + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState, ConfigFlow +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from tests.common import ( + MockConfigEntry, + MockModule, + MockPlatform, + mock_config_flow, + mock_integration, + mock_platform, +) +from tests.typing import WebSocketGenerator + +TEST_DOMAIN = "test" + + +class MockFlow(ConfigFlow): + """Test flow.""" + + +@pytest.fixture(autouse=True) +def config_flow_fixture(hass: HomeAssistant) -> Generator[None, None, None]: + """Mock config flow.""" + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + + with mock_config_flow(TEST_DOMAIN, MockFlow): + yield + + +@pytest.fixture(autouse=True) +def mock_setup_integration(hass: HomeAssistant) -> None: + """Fixture to set up a mock integration.""" + + async def async_setup_entry_init( + hass: HomeAssistant, config_entry: ConfigEntry + ) -> bool: + """Set up test config entry.""" + await hass.config_entries.async_forward_entry_setup(config_entry, DOMAIN) + return True + + async def async_unload_entry_init( + hass: HomeAssistant, + config_entry: ConfigEntry, + ) -> bool: + await hass.config_entries.async_unload_platforms(config_entry, [Platform.TODO]) + return True + + mock_platform(hass, f"{TEST_DOMAIN}.config_flow") + mock_integration( + hass, + MockModule( + TEST_DOMAIN, + async_setup_entry=async_setup_entry_init, + async_unload_entry=async_unload_entry_init, + ), + ) + + +async def create_mock_platform( + hass: HomeAssistant, + entities: list[TodoListEntity], +) -> MockConfigEntry: + """Create a todo platform with the specified entities.""" + + async def async_setup_entry_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: + """Set up test event platform via config entry.""" + async_add_entities(entities) + + mock_platform( + hass, + f"{TEST_DOMAIN}.{DOMAIN}", + MockPlatform(async_setup_entry=async_setup_entry_platform), + ) + + config_entry = MockConfigEntry(domain=TEST_DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="test_entity") +def mock_test_entity() -> TodoListEntity: + """Fixture that creates a test TodoList entity with mock service calls.""" + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + entity1._attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + entity1._attr_todo_items = [ + TodoItem(summary="Item #1", uid="1", status=TodoItemStatus.NEEDS_ACTION), + TodoItem(summary="Item #2", uid="2", status=TodoItemStatus.COMPLETED), + ] + entity1.async_create_todo_item = AsyncMock() + entity1.async_update_todo_item = AsyncMock() + entity1.async_delete_todo_items = AsyncMock() + entity1.async_move_todo_item = AsyncMock() + return entity1 + + +async def test_unload_entry( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test unloading a config entry with a todo entity.""" + + config_entry = await create_mock_platform(hass, [test_entity]) + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get("todo.entity1") + assert state + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + state = hass.states.get("todo.entity1") + assert not state + + +async def test_list_todo_items( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + test_entity: TodoListEntity, +) -> None: + """Test listing items in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + state = hass.states.get("todo.entity1") + assert state + assert state.state == "1" + assert state.attributes == {"supported_features": 15} + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "todo/item/list", "entity_id": "todo.entity1"} + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + assert resp.get("result") == { + "items": [ + {"summary": "Item #1", "uid": "1", "status": "needs_action"}, + {"summary": "Item #2", "uid": "2", "status": "completed"}, + ] + } + + +async def test_unsupported_websocket( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test a To-do list that does not support features.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "todo/item/list", + "entity_id": "todo.unknown", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "not_found" + + +@pytest.mark.parametrize( + ("item_data", "expected_status"), + [ + ({}, TodoItemStatus.NEEDS_ACTION), + ({"status": "needs_action"}, TodoItemStatus.NEEDS_ACTION), + ({"status": "completed"}, TodoItemStatus.COMPLETED), + ], +) +async def test_create_item_service( + hass: HomeAssistant, + item_data: dict[str, Any], + expected_status: TodoItemStatus, + test_entity: TodoListEntity, +) -> None: + """Test creating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "create_item", + {"summary": "New item", **item_data}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_create_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid is None + assert item.summary == "New item" + assert item.status == expected_status + + +async def test_create_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test creating an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_create_todo_item.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "create_item", + {"summary": "New item", "status": "needs_action"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("item_data", "expected_error"), + [ + ({}, "required key not provided"), + ({"status": "needs_action"}, "required key not provided"), + ( + {"summary": "", "status": "needs_action"}, + "length of value must be at least 1", + ), + ], +) +async def test_create_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_error: str, +) -> None: + """Test invalid input to the create item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(vol.Invalid, match=expected_error): + await hass.services.async_call( + DOMAIN, + "create_item", + item_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_update_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "item-1" + assert item.summary == "Updated item" + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_id_status_only( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"uid": "item-1", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "item-1" + assert item.summary is None + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_id_summary_only( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"uid": "item-1", "summary": "Updated item"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "item-1" + assert item.summary == "Updated item" + assert item.status is None + + +async def test_update_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + test_entity.async_update_todo_item.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_update_todo_item_service_by_summary( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"summary": "Item #1", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Item #1" + assert item.status == TodoItemStatus.COMPLETED + + +async def test_update_todo_item_service_by_summary_not_found( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary which is not found.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="Unable to find"): + await hass.services.async_call( + DOMAIN, + "update_item", + {"summary": "Item #7", "status": "completed"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("item_data", "expected_error"), + [ + ({}, "must contain at least one of"), + ({"status": "needs_action"}, "must contain at least one of"), + ( + {"summary": "", "status": "needs_action"}, + "length of value must be at least 1", + ), + ], +) +async def test_update_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + item_data: dict[str, Any], + expected_error: str, +) -> None: + """Test invalid input to the update item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(vol.Invalid, match=expected_error): + await hass.services.async_call( + DOMAIN, + "update_item", + item_data, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_delete_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test deleting an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "delete_item", + {"uid": ["item-1", "item-2"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["item-1", "item-2"] + + +async def test_delete_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test deleting an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_delete_todo_items.side_effect = HomeAssistantError("Ooops") + with pytest.raises(HomeAssistantError, match="Ooops"): + await hass.services.async_call( + DOMAIN, + "delete_item", + {"uid": ["item-1", "item-2"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_delete_todo_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test invalid input to the delete item service.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(vol.Invalid, match="must contain at least one of"): + await hass.services.async_call( + DOMAIN, + "delete_item", + {}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_delete_todo_item_service_by_summary( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test deleting an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "delete_item", + {"summary": ["Item #1"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_delete_todo_items.call_args + assert args + assert args.kwargs.get("uids") == ["1"] + + +async def test_delete_todo_item_service_by_summary_not_found( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test deleting an item in a To-do list by summary which is not found.""" + + await create_mock_platform(hass, [test_entity]) + + with pytest.raises(ValueError, match="Unable to find"): + await hass.services.async_call( + DOMAIN, + "delete_item", + {"summary": ["Item #7"]}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_move_todo_item_service_by_id( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving an item in a To-do list.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "pos": "1", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("success") + + args = test_entity.async_move_todo_item.call_args + assert args + assert args.kwargs.get("uid") == "item-1" + assert args.kwargs.get("pos") == 1 + + +async def test_move_todo_item_service_raises( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving an item in a To-do list that raises an error.""" + + await create_mock_platform(hass, [test_entity]) + + test_entity.async_move_todo_item.side_effect = HomeAssistantError("Ooops") + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "pos": "1", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "failed" + assert resp.get("error", {}).get("message") == "Ooops" + + +@pytest.mark.parametrize( + ("item_data", "expected_status", "expected_error"), + [ + ( + {"entity_id": "todo.unknown", "uid": "item-1"}, + "not_found", + "Entity not found", + ), + ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), + ( + {"entity_id": "todo.entity1", "pos": "2"}, + "invalid_format", + "required key not provided", + ), + ( + {"entity_id": "todo.entity1", "uid": "item-1", "pos": "-2"}, + "invalid_format", + "value must be at least 0", + ), + ], +) +async def test_move_todo_item_service_invalid_input( + hass: HomeAssistant, + test_entity: TodoListEntity, + hass_ws_client: WebSocketGenerator, + item_data: dict[str, Any], + expected_status: str, + expected_error: str, +) -> None: + """Test invalid input for the move item service.""" + + await create_mock_platform(hass, [test_entity]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + **item_data, + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == expected_status + assert expected_error in resp.get("error", {}).get("message") + + +@pytest.mark.parametrize( + ("service_name", "payload"), + [ + ( + "create_item", + { + "summary": "New item", + }, + ), + ( + "delete_item", + { + "uid": ["1"], + }, + ), + ( + "update_item", + { + "uid": "1", + "summary": "Updated item", + }, + ), + ], +) +async def test_unsupported_service( + hass: HomeAssistant, + service_name: str, + payload: dict[str, Any], +) -> None: + """Test a To-do list that does not support features.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + with pytest.raises( + HomeAssistantError, + match="does not support this service", + ): + await hass.services.async_call( + DOMAIN, + service_name, + payload, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + +async def test_move_item_unsupported( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test invalid input for the move item service.""" + + entity1 = TodoListEntity() + entity1.entity_id = "todo.entity1" + await create_mock_platform(hass, [entity1]) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "todo/item/move", + "entity_id": "todo.entity1", + "uid": "item-1", + "pos": "1", + } + ) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert resp.get("error", {}).get("code") == "not_supported" From 5245c943424bdf7ba7643bf5571e0527e64bea7b Mon Sep 17 00:00:00 2001 From: ollo69 <60491700+ollo69@users.noreply.github.com> Date: Mon, 23 Oct 2023 23:16:27 +0200 Subject: [PATCH 747/968] Exclude AsusWRT tracker state attribute from recorder (#102602) --- homeassistant/components/asuswrt/device_tracker.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py index 5d923abf744..fc0a9ee539e 100644 --- a/homeassistant/components/asuswrt/device_tracker.py +++ b/homeassistant/components/asuswrt/device_tracker.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_ASUSWRT, DOMAIN from .router import AsusWrtDevInfo, AsusWrtRouter +ATTR_LAST_TIME_REACHABLE = "last_time_reachable" + DEFAULT_DEVICE_NAME = "Unknown device" @@ -52,6 +54,8 @@ def add_entities( class AsusWrtDevice(ScannerEntity): """Representation of a AsusWrt device.""" + _unrecorded_attributes = frozenset({ATTR_LAST_TIME_REACHABLE}) + _attr_should_poll = False def __init__(self, router: AsusWrtRouter, device: AsusWrtDevInfo) -> None: @@ -97,7 +101,7 @@ class AsusWrtDevice(ScannerEntity): self._attr_extra_state_attributes = {} if self._device.last_activity: self._attr_extra_state_attributes[ - "last_time_reachable" + ATTR_LAST_TIME_REACHABLE ] = self._device.last_activity.isoformat(timespec="seconds") self.async_write_ha_state() From 2935d7d919bbd0f2e860ea6e220aa6a6c32ea694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Mon, 23 Oct 2023 22:57:58 +0100 Subject: [PATCH 748/968] Remove uneeded typing on Idasen Desk (#102615) --- homeassistant/components/idasen_desk/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py index fb905bc6c4f..9496752dce7 100644 --- a/homeassistant/components/idasen_desk/__init__.py +++ b/homeassistant/components/idasen_desk/__init__.py @@ -91,10 +91,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up IKEA Idasen from a config entry.""" address: str = entry.data[CONF_ADDRESS].upper() - coordinator: IdasenDeskCoordinator = IdasenDeskCoordinator( - hass, _LOGGER, entry.title, address - ) - + coordinator = IdasenDeskCoordinator(hass, _LOGGER, entry.title, address) device_info = DeviceInfo( name=entry.title, connections={(dr.CONNECTION_BLUETOOTH, address)}, From d5e7cccff971b94a15c511accaaf2dac21cb85a6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 00:15:58 +0200 Subject: [PATCH 749/968] Add serial number to Brother (#102523) --- homeassistant/components/brother/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 4ea6f7abbad..e9554d84207 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -383,6 +383,7 @@ async def async_setup_entry( device_info = DeviceInfo( configuration_url=f"http://{entry.data[CONF_HOST]}/", identifiers={(DOMAIN, coordinator.data.serial)}, + serial_number=coordinator.data.serial, manufacturer="Brother", model=coordinator.data.model, name=coordinator.data.model, From 6372bc3aaa7af00d4ca3d1064d59978ea1c1ce60 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 24 Oct 2023 00:20:03 +0200 Subject: [PATCH 750/968] Fix missed case alexa light attr can be None (#102612) * Fix missed cased alexa light attr can be None * Add test --- .../components/alexa/capabilities.py | 12 +++-- tests/components/alexa/test_smart_home.py | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 5d749fdf430..cde90e127f3 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -630,12 +630,16 @@ class AlexaColorController(AlexaCapability): if name != "color": raise UnsupportedProperty(name) - hue, saturation = self.entity.attributes.get(light.ATTR_HS_COLOR, (0, 0)) + hue_saturation: tuple[float, float] | None + if (hue_saturation := self.entity.attributes.get(light.ATTR_HS_COLOR)) is None: + hue_saturation = (0, 0) + if (brightness := self.entity.attributes.get(light.ATTR_BRIGHTNESS)) is None: + brightness = 0 return { - "hue": hue, - "saturation": saturation / 100.0, - "brightness": self.entity.attributes.get(light.ATTR_BRIGHTNESS, 0) / 255.0, + "hue": hue_saturation[0], + "saturation": hue_saturation[1] / 100.0, + "brightness": brightness / 255.0, } diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 99dd79fe2e2..e24ec4c950b 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -350,6 +350,55 @@ async def test_color_light( # tests +async def test_color_light_turned_off(hass: HomeAssistant) -> None: + """Test color light discovery with turned off light.""" + device = ( + "light.test_off", + "off", + { + "friendly_name": "Test light off", + "supported_color_modes": ["color_temp", "hs"], + "hs_color": None, + "color_temp": None, + "brightness": None, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "light#test_off" + assert appliance["displayCategories"][0] == "LIGHT" + assert appliance["friendlyName"] == "Test light off" + + assert_endpoint_capabilities( + appliance, + "Alexa.BrightnessController", + "Alexa.PowerController", + "Alexa.ColorController", + "Alexa.ColorTemperatureController", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "light#test_off") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + properties.assert_equal("Alexa.BrightnessController", "brightness", 0) + properties.assert_equal( + "Alexa.ColorController", + "color", + {"hue": 0.0, "saturation": 0.0, "brightness": 0.0}, + ) + + call, _ = await assert_request_calls_service( + "Alexa.BrightnessController", + "SetBrightness", + "light#test_off", + "light.turn_on", + hass, + payload={"brightness": "50"}, + ) + assert call.data["brightness_pct"] == 50 + + @pytest.mark.freeze_time("2022-04-19 07:53:05") async def test_script(hass: HomeAssistant) -> None: """Test script discovery.""" From b953f2998c7293bba6bca310f025b947e851d72c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 09:11:14 +0200 Subject: [PATCH 751/968] Rename the safe_mode integration to recovery_mode (#102581) * Rename safe mode integration to recovery mode * Update code --- .core_files.yaml | 2 +- CODEOWNERS | 4 ++-- homeassistant/bootstrap.py | 2 +- .../{safe_mode => recovery_mode}/__init__.py | 10 +++++----- .../{safe_mode => recovery_mode}/manifest.json | 6 +++--- script/hassfest/manifest.py | 2 +- tests/components/recovery_mode/__init__.py | 1 + .../{safe_mode => recovery_mode}/test_init.py | 6 +++--- tests/components/safe_mode/__init__.py | 1 - tests/test_bootstrap.py | 18 +++++++++--------- 10 files changed, 26 insertions(+), 26 deletions(-) rename homeassistant/components/{safe_mode => recovery_mode}/__init__.py (70%) rename homeassistant/components/{safe_mode => recovery_mode}/manifest.json (59%) create mode 100644 tests/components/recovery_mode/__init__.py rename tests/components/{safe_mode => recovery_mode}/test_init.py (70%) delete mode 100644 tests/components/safe_mode/__init__.py diff --git a/.core_files.yaml b/.core_files.yaml index 0817d5c8261..b3e854de04b 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -96,8 +96,8 @@ components: &components - homeassistant/components/persistent_notification/** - homeassistant/components/person/** - homeassistant/components/recorder/** + - homeassistant/components/recovery_mode/** - homeassistant/components/repairs/** - - homeassistant/components/safe_mode/** - homeassistant/components/script/** - homeassistant/components/shopping_list/** - homeassistant/components/ssdp/** diff --git a/CODEOWNERS b/CODEOWNERS index 87ac2cebfcb..e763baa7e79 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1035,6 +1035,8 @@ build.json @home-assistant/supervisor /tests/components/recollect_waste/ @bachya /homeassistant/components/recorder/ @home-assistant/core /tests/components/recorder/ @home-assistant/core +/homeassistant/components/recovery_mode/ @home-assistant/core +/tests/components/recovery_mode/ @home-assistant/core /homeassistant/components/rejseplanen/ @DarkFox /homeassistant/components/remote/ @home-assistant/core /tests/components/remote/ @home-assistant/core @@ -1085,8 +1087,6 @@ build.json @home-assistant/supervisor /tests/components/rympro/ @OnFreund @elad-bar @maorcc /homeassistant/components/sabnzbd/ @shaiu /tests/components/sabnzbd/ @shaiu -/homeassistant/components/safe_mode/ @home-assistant/core -/tests/components/safe_mode/ @home-assistant/core /homeassistant/components/saj/ @fredericvl /homeassistant/components/samsungtv/ @chemelli74 @epenet /tests/components/samsungtv/ @chemelli74 @epenet diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7f6c29d8105..89aa5c05d0d 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -194,7 +194,7 @@ async def async_setup_hass( http_conf = (await http.async_get_last_config(hass)) or {} await async_from_config_dict( - {"safe_mode": {}, "http": http_conf}, + {"recovery_mode": {}, "http": http_conf}, hass, ) diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/recovery_mode/__init__.py similarity index 70% rename from homeassistant/components/safe_mode/__init__.py rename to homeassistant/components/recovery_mode/__init__.py index 3ed2d4476af..46a8d320663 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/recovery_mode/__init__.py @@ -1,22 +1,22 @@ -"""The Safe Mode integration.""" +"""The Recovery Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -DOMAIN = "safe_mode" +DOMAIN = "recovery_mode" CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Safe Mode component.""" + """Set up the Recovery Mode component.""" persistent_notification.async_create( hass, ( - "Home Assistant is running in safe mode. Check [the error" + "Home Assistant is running in recovery mode. Check [the error" " log](/config/logs) to see what went wrong." ), - "Safe Mode", + "Recovery Mode", ) return True diff --git a/homeassistant/components/safe_mode/manifest.json b/homeassistant/components/recovery_mode/manifest.json similarity index 59% rename from homeassistant/components/safe_mode/manifest.json rename to homeassistant/components/recovery_mode/manifest.json index 344b530db2e..1e46a4acde6 100644 --- a/homeassistant/components/safe_mode/manifest.json +++ b/homeassistant/components/recovery_mode/manifest.json @@ -1,10 +1,10 @@ { - "domain": "safe_mode", - "name": "Safe Mode", + "domain": "recovery_mode", + "name": "Recovery Mode", "codeowners": ["@home-assistant/core"], "config_flow": false, "dependencies": ["frontend", "persistent_notification", "cloud"], - "documentation": "https://www.home-assistant.io/integrations/safe_mode", + "documentation": "https://www.home-assistant.io/integrations/recovery_mode", "integration_type": "system", "quality_scale": "internal" } diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index acdea23444d..d5acde61262 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -98,8 +98,8 @@ NO_IOT_CLASS = [ "proxy", "python_script", "raspberry_pi", + "recovery_mode", "repairs", - "safe_mode", "schedule", "script", "search", diff --git a/tests/components/recovery_mode/__init__.py b/tests/components/recovery_mode/__init__.py new file mode 100644 index 00000000000..1f2f2fcadd8 --- /dev/null +++ b/tests/components/recovery_mode/__init__.py @@ -0,0 +1 @@ +"""Tests for the Recovery Mode integration.""" diff --git a/tests/components/safe_mode/test_init.py b/tests/components/recovery_mode/test_init.py similarity index 70% rename from tests/components/safe_mode/test_init.py rename to tests/components/recovery_mode/test_init.py index 82f5f5180da..ec8db443ef1 100644 --- a/tests/components/safe_mode/test_init.py +++ b/tests/components/recovery_mode/test_init.py @@ -1,4 +1,4 @@ -"""Tests for safe mode integration.""" +"""Tests for the Recovery Mode integration.""" from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -6,8 +6,8 @@ from tests.common import async_get_persistent_notifications async def test_works(hass: HomeAssistant) -> None: - """Test safe mode works.""" - assert await async_setup_component(hass, "safe_mode", {}) + """Test Recovery Mode works.""" + assert await async_setup_component(hass, "recovery_mode", {}) await hass.async_block_till_done() notifications = async_get_persistent_notifications(hass) assert len(notifications) == 1 diff --git a/tests/components/safe_mode/__init__.py b/tests/components/safe_mode/__init__.py deleted file mode 100644 index 3732fef17cb..00000000000 --- a/tests/components/safe_mode/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Safe Mode integration.""" diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 6938acb9cc9..d7901b0566e 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -103,7 +103,7 @@ async def test_empty_setup(hass: HomeAssistant) -> None: assert domain in hass.config.components, domain -async def test_core_failure_loads_safe_mode( +async def test_core_failure_loads_recovery_mode( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: """Test failing core setup aborts further setup.""" @@ -495,7 +495,7 @@ async def test_setup_hass( assert "Waiting on integrations to complete setup" not in caplog.text assert "browser" in hass.config.components - assert "safe_mode" not in hass.config.components + assert "recovery_mode" not in hass.config.components assert len(mock_enable_logging.mock_calls) == 1 assert mock_enable_logging.mock_calls[0][1] == ( @@ -578,7 +578,7 @@ async def test_setup_hass_invalid_yaml( ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 @@ -609,7 +609,7 @@ async def test_setup_hass_config_dir_nonexistent( ) -async def test_setup_hass_safe_mode( +async def test_setup_hass_recovery_mode( mock_enable_logging: Mock, mock_is_virtual_env: Mock, mock_mount_local_lib_path: AsyncMock, @@ -634,7 +634,7 @@ async def test_setup_hass_safe_mode( ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert len(mock_mount_local_lib_path.mock_calls) == 0 # Validate we didn't try to set up config entry. @@ -665,7 +665,7 @@ async def test_setup_hass_invalid_core_config( ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components @pytest.mark.parametrize( @@ -681,7 +681,7 @@ async def test_setup_hass_invalid_core_config( } ], ) -async def test_setup_safe_mode_if_no_frontend( +async def test_setup_recovery_mode_if_no_frontend( mock_hass_config: None, mock_enable_logging: Mock, mock_is_virtual_env: Mock, @@ -690,7 +690,7 @@ async def test_setup_safe_mode_if_no_frontend( mock_process_ha_config_upgrade: Mock, event_loop: asyncio.AbstractEventLoop, ) -> None: - """Test we setup safe mode if frontend didn't load.""" + """Test we setup recovery mode if frontend didn't load.""" verbose = Mock() log_rotate_days = Mock() log_file = Mock() @@ -708,7 +708,7 @@ async def test_setup_safe_mode_if_no_frontend( ), ) - assert "safe_mode" in hass.config.components + assert "recovery_mode" in hass.config.components assert hass.config.config_dir == get_test_config_dir() assert hass.config.skip_pip assert hass.config.internal_url == "http://192.168.1.100:8123" From b0d4e5cb65ba1d217c6c88816e818b62a214f9cc Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 24 Oct 2023 09:20:28 +0200 Subject: [PATCH 752/968] =?UTF-8?q?Retire=20Niels=20M=C3=BCndler=20from=20?= =?UTF-8?q?Fronius=20codeowners=20(#102639)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODEOWNERS | 4 ++-- homeassistant/components/fronius/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e763baa7e79..e967f4d65e4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -423,8 +423,8 @@ build.json @home-assistant/supervisor /tests/components/fritzbox/ @mib1185 @flabbamann /homeassistant/components/fritzbox_callmonitor/ @cdce8p /tests/components/fritzbox_callmonitor/ @cdce8p -/homeassistant/components/fronius/ @nielstron @farmio -/tests/components/fronius/ @nielstron @farmio +/homeassistant/components/fronius/ @farmio +/tests/components/fronius/ @farmio /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend /homeassistant/components/frontier_silicon/ @wlcrs diff --git a/homeassistant/components/fronius/manifest.json b/homeassistant/components/fronius/manifest.json index bbe0f452bea..1ec62c54b6c 100644 --- a/homeassistant/components/fronius/manifest.json +++ b/homeassistant/components/fronius/manifest.json @@ -1,7 +1,7 @@ { "domain": "fronius", "name": "Fronius", - "codeowners": ["@nielstron", "@farmio"], + "codeowners": ["@farmio"], "config_flow": true, "dhcp": [ { From 57a10a2e0dcb8c87fad8ee03fec28837157103ee Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 24 Oct 2023 10:32:27 +0200 Subject: [PATCH 753/968] Set cart icon for shopping list integration (#102638) --- homeassistant/components/shopping_list/todo.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index fd83f138392..53c9e6b6d74 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -32,6 +32,7 @@ class ShoppingTodoListEntity(TodoListEntity): """A To-do List representation of the Shopping List.""" _attr_has_entity_name = True + _attr_icon = "mdi:cart" _attr_translation_key = "shopping_list" _attr_should_poll = False _attr_supported_features = ( From b42c47e800e6be6a370d33d602948331b151980b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 11:07:47 +0200 Subject: [PATCH 754/968] Add last workout sensors to Withings (#102541) Co-authored-by: J. Nick Koston Co-authored-by: Robert Resch --- homeassistant/components/withings/__init__.py | 4 + .../components/withings/coordinator.py | 37 ++ homeassistant/components/withings/sensor.py | 146 +++++++- .../components/withings/strings.json | 72 ++++ tests/components/withings/__init__.py | 12 +- tests/components/withings/conftest.py | 5 + .../withings/fixtures/workouts.json | 327 ++++++++++++++++++ .../withings/snapshots/test_sensor.ambr | 158 ++++++++- tests/components/withings/test_sensor.py | 48 +++ 9 files changed, 793 insertions(+), 16 deletions(-) create mode 100644 tests/components/withings/fixtures/workouts.json diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 92cec96ce97..2158b169844 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -59,6 +59,7 @@ from .coordinator import ( WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -133,6 +134,7 @@ class WithingsData: bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator goals_coordinator: WithingsGoalsDataUpdateCoordinator activity_coordinator: WithingsActivityDataUpdateCoordinator + workout_coordinator: WithingsWorkoutDataUpdateCoordinator coordinators: set[WithingsDataUpdateCoordinator] = field(default_factory=set) def __post_init__(self) -> None: @@ -143,6 +145,7 @@ class WithingsData: self.bed_presence_coordinator, self.goals_coordinator, self.activity_coordinator, + self.workout_coordinator, } @@ -176,6 +179,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), goals_coordinator=WithingsGoalsDataUpdateCoordinator(hass, client), activity_coordinator=WithingsActivityDataUpdateCoordinator(hass, client), + workout_coordinator=WithingsWorkoutDataUpdateCoordinator(hass, client), ) for coordinator in withings_data.coordinators: diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 7964a755b4d..35eeb6e62b6 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -13,6 +13,7 @@ from aiowithings import ( WithingsAuthenticationFailedError, WithingsClient, WithingsUnauthorizedError, + Workout, aggregate_measurements, ) @@ -224,3 +225,39 @@ class WithingsActivityDataUpdateCoordinator( if self._previous_data and self._previous_data.date == today: return self._previous_data return None + + +class WithingsWorkoutDataUpdateCoordinator( + WithingsDataUpdateCoordinator[Workout | None] +): + """Withings workout coordinator.""" + + _previous_data: Workout | None = None + + def __init__(self, hass: HomeAssistant, client: WithingsClient) -> None: + """Initialize the Withings data coordinator.""" + super().__init__(hass, client) + self.notification_categories = { + NotificationCategory.ACTIVITY, + } + + async def _internal_update_data(self) -> Workout | None: + """Retrieve latest workout.""" + if self._last_valid_update is None: + now = dt_util.utcnow() + startdate = now - timedelta(days=14) + workouts = await self._client.get_workouts_in_period( + startdate.date(), now.date() + ) + else: + workouts = await self._client.get_workouts_since(self._last_valid_update) + if not workouts: + return self._previous_data + latest_workout = max(workouts, key=lambda workout: workout.end_date) + if ( + self._previous_data is None + or self._previous_data.end_date >= latest_workout.end_date + ): + self._previous_data = latest_workout + self._last_valid_update = latest_workout.end_date + return self._previous_data diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index a531bf49986..4729671fa3b 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -5,7 +5,14 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from aiowithings import Activity, Goals, MeasurementType, SleepSummary +from aiowithings import ( + Activity, + Goals, + MeasurementType, + SleepSummary, + Workout, + WorkoutCategory, +) from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,6 +51,7 @@ from .coordinator import ( WithingsGoalsDataUpdateCoordinator, WithingsMeasurementDataUpdateCoordinator, WithingsSleepDataUpdateCoordinator, + WithingsWorkoutDataUpdateCoordinator, ) from .entity import WithingsEntity @@ -420,7 +428,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.steps, translation_key="activity_steps_today", icon="mdi:shoe-print", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -438,7 +446,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.floors_climbed, translation_key="activity_floors_climbed_today", icon="mdi:stairs-up", - native_unit_of_measurement="Floors", + native_unit_of_measurement="floors", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -485,7 +493,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.active_calories_burnt, suggested_display_precision=1, translation_key="activity_active_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), WithingsActivitySensorEntityDescription( @@ -493,7 +501,7 @@ ACTIVITY_SENSORS = [ value_fn=lambda activity: activity.total_calories_burnt, suggested_display_precision=1, translation_key="activity_total_calories_burnt_today", - native_unit_of_measurement="Calories", + native_unit_of_measurement="calories", state_class=SensorStateClass.TOTAL, ), ] @@ -524,7 +532,7 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { value_fn=lambda goals: goals.steps, icon="mdi:shoe-print", translation_key="step_goal", - native_unit_of_measurement="Steps", + native_unit_of_measurement="steps", state_class=SensorStateClass.MEASUREMENT, ), SLEEP_GOAL: WithingsGoalsSensorEntityDescription( @@ -548,6 +556,84 @@ GOALS_SENSORS: dict[str, WithingsGoalsSensorEntityDescription] = { } +@dataclass +class WithingsWorkoutSensorEntityDescriptionMixin: + """Mixin for describing withings data.""" + + value_fn: Callable[[Workout], StateType] + + +@dataclass +class WithingsWorkoutSensorEntityDescription( + SensorEntityDescription, WithingsWorkoutSensorEntityDescriptionMixin +): + """Immutable class for describing withings data.""" + + +_WORKOUT_CATEGORY = [ + workout_category.name.lower() for workout_category in WorkoutCategory +] + + +WORKOUT_SENSORS = [ + WithingsWorkoutSensorEntityDescription( + key="workout_type", + value_fn=lambda workout: workout.category.name.lower(), + device_class=SensorDeviceClass.ENUM, + translation_key="workout_type", + options=_WORKOUT_CATEGORY, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_active_calories_burnt", + value_fn=lambda workout: workout.active_calories_burnt, + translation_key="workout_active_calories_burnt", + suggested_display_precision=1, + native_unit_of_measurement="calories", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_distance", + value_fn=lambda workout: workout.distance, + translation_key="workout_distance", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.METERS, + suggested_display_precision=0, + icon="mdi:map-marker-distance", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_floors_climbed", + value_fn=lambda workout: workout.floors_climbed, + translation_key="workout_floors_climbed", + icon="mdi:stairs-up", + native_unit_of_measurement="floors", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_intensity", + value_fn=lambda workout: workout.intensity, + translation_key="workout_intensity", + ), + WithingsWorkoutSensorEntityDescription( + key="workout_pause_duration", + value_fn=lambda workout: workout.pause_duration or 0, + translation_key="workout_pause_duration", + icon="mdi:timer-pause", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), + WithingsWorkoutSensorEntityDescription( + key="workout_duration", + value_fn=lambda workout: ( + workout.end_date - workout.start_date + ).total_seconds(), + translation_key="workout_duration", + icon="mdi:timer", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + suggested_unit_of_measurement=UnitOfTime.MINUTES, + ), +] + + def get_current_goals(goals: Goals) -> set[str]: """Return a list of present goals.""" result = set() @@ -656,7 +742,7 @@ async def async_setup_entry( for attribute in SLEEP_SENSORS ) else: - remove_listener: Callable[[], None] + remove_sleep_listener: Callable[[], None] def _async_add_sleep_entities() -> None: """Add sleep entities.""" @@ -665,12 +751,39 @@ async def async_setup_entry( WithingsSleepSensor(sleep_coordinator, attribute) for attribute in SLEEP_SENSORS ) - remove_listener() + remove_sleep_listener() - remove_listener = sleep_coordinator.async_add_listener( + remove_sleep_listener = sleep_coordinator.async_add_listener( _async_add_sleep_entities ) + workout_coordinator = withings_data.workout_coordinator + + workout_entities_setup_before = ent_reg.async_get_entity_id( + Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_workout_type" + ) + + if workout_coordinator.data is not None or workout_entities_setup_before: + entities.extend( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + else: + remove_workout_listener: Callable[[], None] + + def _async_add_workout_entities() -> None: + """Add workout entities.""" + if workout_coordinator.data is not None: + async_add_entities( + WithingsWorkoutSensor(workout_coordinator, attribute) + for attribute in WORKOUT_SENSORS + ) + remove_workout_listener() + + remove_workout_listener = workout_coordinator.async_add_listener( + _async_add_workout_entities + ) + async_add_entities(entities) @@ -755,3 +868,18 @@ class WithingsActivitySensor(WithingsSensor): def last_reset(self) -> datetime: """These values reset every day.""" return dt_util.start_of_local_day() + + +class WithingsWorkoutSensor(WithingsSensor): + """Implementation of a Withings workout sensor.""" + + coordinator: WithingsWorkoutDataUpdateCoordinator + + entity_description: WithingsWorkoutSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the state of the entity.""" + if not self.coordinator.data: + return None + return self.entity_description.value_fn(self.coordinator.data) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index a6a832d8394..dcb63f22a2e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -170,6 +170,78 @@ }, "activity_total_calories_burnt_today": { "name": "Total calories burnt today" + }, + "workout_type": { + "name": "Last workout type", + "state": { + "walk": "Walking", + "run": "Running", + "hiking": "Hiking", + "skating": "Skating", + "bmx": "BMX", + "bicycling": "Bicycling", + "swimming": "Swimming", + "surfing": "Surfing", + "kitesurfing": "Kitesurfing", + "windsurfing": "Windsurfing", + "bodyboard": "Bodyboard", + "tennis": "Tennis", + "table_tennis": "Table tennis", + "squash": "Squash", + "badminton": "Badminton", + "lift_weights": "Lift weights", + "calisthenics": "Calisthenics", + "elliptical": "Elliptical", + "pilates": "Pilates", + "basket_ball": "Basket ball", + "soccer": "Soccer", + "football": "Football", + "rugby": "Rugby", + "volley_ball": "Volley ball", + "waterpolo": "Waterpolo", + "horse_riding": "Horse riding", + "golf": "Golf", + "yoga": "Yoga", + "dancing": "Dancing", + "boxing": "Boxing", + "fencing": "Fencing", + "wrestling": "Wrestling", + "martial_arts": "Martial arts", + "skiing": "Skiing", + "snowboarding": "Snowboarding", + "other": "Other", + "no_activity": "No activity", + "rowing": "Rowing", + "zumba": "Zumba", + "baseball": "Baseball", + "handball": "Handball", + "hockey": "Hockey", + "ice_hockey": "Ice hockey", + "climbing": "Climbing", + "ice_skating": "Ice skating", + "multi_sport": "Multi sport", + "indoor_walk": "Indoor walking", + "indoor_running": "Indoor running", + "indoor_cycling": "Indoor cycling" + } + }, + "workout_active_calories_burnt": { + "name": "Calories burnt last workout" + }, + "workout_distance": { + "name": "Distance travelled last workout" + }, + "workout_floors_climbed": { + "name": "Floors climbed last workout" + }, + "workout_intensity": { + "name": "Last workout intensity" + }, + "workout_pause_duration": { + "name": "Pause during last workout" + }, + "workout_duration": { + "name": "Last workout duration" } } } diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 8d8207cdf9a..cd0e9994f74 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -5,7 +5,7 @@ from typing import Any from urllib.parse import urlparse from aiohttp.test_utils import TestClient -from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary +from aiowithings import Activity, Goals, MeasurementGroup, SleepSummary, Workout from freezegun.api import FrozenDateTimeFactory from homeassistant.components.webhook import async_generate_url @@ -89,11 +89,19 @@ def load_measurements_fixture( def load_activity_fixture( fixture: str = "withings/activity.json", ) -> list[Activity]: - """Return measurement from fixture.""" + """Return activities from fixture.""" activity_json = load_json_array_fixture(fixture) return [Activity.from_api(activity) for activity in activity_json] +def load_workout_fixture( + fixture: str = "withings/workouts.json", +) -> list[Workout]: + """Return workouts from fixture.""" + workouts_json = load_json_array_fixture(fixture) + return [Workout.from_api(workout) for workout in workouts_json] + + def load_sleep_fixture( fixture: str = "withings/sleep_summaries.json", ) -> list[SleepSummary]: diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index b040ccd2b58..7f15c5e0252 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -21,6 +21,7 @@ from tests.components.withings import ( load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, ) CLIENT_ID = "1234" @@ -144,6 +145,8 @@ def mock_withings(): NotificationConfiguration.from_api(not_conf) for not_conf in notification_json ] + workouts = load_workout_fixture() + activities = load_activity_fixture() mock = AsyncMock(spec=WithingsClient) @@ -155,6 +158,8 @@ def mock_withings(): mock.get_activities_since.return_value = activities mock.get_activities_in_period.return_value = activities mock.list_notification_configurations.return_value = notifications + mock.get_workouts_since.return_value = workouts + mock.get_workouts_in_period.return_value = workouts with patch( "homeassistant.components.withings.WithingsClient", diff --git a/tests/components/withings/fixtures/workouts.json b/tests/components/withings/fixtures/workouts.json new file mode 100644 index 00000000000..d5edcc75580 --- /dev/null +++ b/tests/components/withings/fixtures/workouts.json @@ -0,0 +1,327 @@ +[ + { + "id": 3661300277, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693336011, + "enddate": 1693336513, + "date": "2023-08-29", + "deviceid": null, + "data": { + "calories": 47, + "intensity": 30, + "manual_distance": 60, + "manual_calories": 70, + "hr_average": 80, + "hr_min": 70, + "hr_max": 80, + "hr_zone_0": 100, + "hr_zone_1": 200, + "hr_zone_2": 300, + "hr_zone_3": 400, + "pause_duration": 80, + "steps": 779, + "distance": 680, + "elevation": 10, + "algo_pause_duration": null, + "spo2_average": 15 + }, + "modified": 1693481873 + }, + { + "id": 3661300290, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1693469307, + "enddate": 1693469924, + "date": "2023-08-31", + "deviceid": null, + "data": { + "algo_pause_duration": null + }, + "modified": 1693481873 + }, + { + "id": 3661300269, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1691164839, + "enddate": 1691165719, + "date": "2023-08-04", + "deviceid": null, + "data": { + "calories": 82, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1450, + "distance": 1294, + "elevation": 18, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1693481873 + }, + { + "id": 3743596080, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695425635, + "enddate": 1695426661, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 97, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1650, + "distance": 1405, + "elevation": 19, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596073, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694715649, + "enddate": 1694716306, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 62, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 1076, + "distance": 917, + "elevation": 15, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596085, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1695426953, + "enddate": 1695427093, + "date": "2023-09-23", + "deviceid": null, + "data": { + "calories": 13, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 216, + "distance": 185, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3743596072, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1694713351, + "enddate": 1694715327, + "date": "2023-09-14", + "deviceid": null, + "data": { + "calories": 187, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 3339, + "distance": 2908, + "elevation": 49, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1696672530 + }, + { + "id": 3752609171, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696835569, + "enddate": 1696835767, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 18, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 291, + "distance": 261, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609178, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696844383, + "enddate": 1696844638, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 24, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 267, + "distance": 232, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + }, + { + "id": 3752609174, + "category": 1, + "timezone": "Europe/Amsterdam", + "model": 1055, + "attrib": 0, + "startdate": 1696842803, + "enddate": 1696843032, + "date": "2023-10-09", + "deviceid": null, + "data": { + "calories": 21, + "intensity": 30, + "manual_distance": 0, + "manual_calories": 0, + "hr_average": 0, + "hr_min": 0, + "hr_max": 0, + "hr_zone_0": 0, + "hr_zone_1": 0, + "hr_zone_2": 0, + "hr_zone_3": 0, + "pause_duration": 0, + "steps": 403, + "distance": 359, + "elevation": 4, + "algo_pause_duration": null, + "spo2_average": null + }, + "modified": 1697038119 + } +] diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr index 75d87a23a9c..59d9b470247 100644 --- a/tests/components/withings/snapshots/test_sensor.ambr +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -5,7 +5,7 @@ 'friendly_name': 'henk Active calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_active_calories_burnt_today', @@ -103,6 +103,19 @@ 'state': '9', }) # --- +# name: test_all_entities[sensor.henk_calories_burnt_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Calories burnt last workout', + 'unit_of_measurement': 'calories', + }), + 'context': , + 'entity_id': 'sensor.henk_calories_burnt_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '24', + }) +# --- # name: test_all_entities[sensor.henk_deep_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -133,6 +146,21 @@ 'state': '70', }) # --- +# name: test_all_entities[sensor.henk_distance_travelled_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'henk Distance travelled last workout', + 'icon': 'mdi:map-marker-distance', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_distance_travelled_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '232', + }) +# --- # name: test_all_entities[sensor.henk_distance_travelled_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -209,6 +237,20 @@ 'state': '0.07', }) # --- +# name: test_all_entities[sensor.henk_floors_climbed_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Floors climbed last workout', + 'icon': 'mdi:stairs-up', + 'unit_of_measurement': 'floors', + }), + 'context': , + 'entity_id': 'sensor.henk_floors_climbed_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '4', + }) +# --- # name: test_all_entities[sensor.henk_floors_climbed_today] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -216,7 +258,7 @@ 'icon': 'mdi:stairs-up', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Floors', + 'unit_of_measurement': 'floors', }), 'context': , 'entity_id': 'sensor.henk_floors_climbed_today', @@ -302,6 +344,97 @@ 'state': '100', }) # --- +# name: test_all_entities[sensor.henk_last_workout_duration] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Last workout duration', + 'icon': 'mdi:timer', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_duration', + 'last_changed': , + 'last_updated': , + 'state': '255.0', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_intensity] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Last workout intensity', + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_intensity', + 'last_changed': , + 'last_updated': , + 'state': '30', + }) +# --- +# name: test_all_entities[sensor.henk_last_workout_type] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'henk Last workout type', + 'options': list([ + 'walk', + 'run', + 'hiking', + 'skating', + 'bmx', + 'bicycling', + 'swimming', + 'surfing', + 'kitesurfing', + 'windsurfing', + 'bodyboard', + 'tennis', + 'table_tennis', + 'squash', + 'badminton', + 'lift_weights', + 'calisthenics', + 'elliptical', + 'pilates', + 'basket_ball', + 'soccer', + 'football', + 'rugby', + 'volley_ball', + 'waterpolo', + 'horse_riding', + 'golf', + 'yoga', + 'dancing', + 'boxing', + 'fencing', + 'wrestling', + 'martial_arts', + 'skiing', + 'snowboarding', + 'other', + 'no_activity', + 'rowing', + 'zumba', + 'baseball', + 'handball', + 'hockey', + 'ice_hockey', + 'climbing', + 'ice_skating', + 'multi_sport', + 'indoor_walk', + 'indoor_running', + 'indoor_cycling', + ]), + }), + 'context': , + 'entity_id': 'sensor.henk_last_workout_type', + 'last_changed': , + 'last_updated': , + 'state': 'walk', + }) +# --- # name: test_all_entities[sensor.henk_light_sleep] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -407,6 +540,21 @@ 'state': '50', }) # --- +# name: test_all_entities[sensor.henk_pause_during_last_workout] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'duration', + 'friendly_name': 'henk Pause during last workout', + 'icon': 'mdi:timer-pause', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_pause_during_last_workout', + 'last_changed': , + 'last_updated': , + 'state': '0', + }) +# --- # name: test_all_entities[sensor.henk_pulse_wave_velocity] StateSnapshot({ 'attributes': ReadOnlyDict({ @@ -546,7 +694,7 @@ 'friendly_name': 'henk Step goal', 'icon': 'mdi:shoe-print', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_step_goal', @@ -562,7 +710,7 @@ 'icon': 'mdi:shoe-print', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Steps', + 'unit_of_measurement': 'steps', }), 'context': , 'entity_id': 'sensor.henk_steps_today', @@ -638,7 +786,7 @@ 'friendly_name': 'henk Total calories burnt today', 'last_reset': '2023-10-20T00:00:00-07:00', 'state_class': , - 'unit_of_measurement': 'Calories', + 'unit_of_measurement': 'calories', }), 'context': , 'entity_id': 'sensor.henk_total_calories_burnt_today', diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index d7add6905e5..0bf6b323146 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -15,6 +15,7 @@ from . import ( load_goals_fixture, load_measurements_fixture, load_sleep_fixture, + load_workout_fixture, setup_integration, ) @@ -293,3 +294,50 @@ async def test_sleep_sensors_created_when_receive_sleep_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_deep_sleep") + + +async def test_workout_sensors_created_when_existed( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if they existed before.""" + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") + assert hass.states.get("sensor.henk_last_workout_type").state != STATE_UNKNOWN + + withings.get_workouts_in_period.return_value = [] + + await hass.config_entries.async_reload(polling_config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type").state == STATE_UNKNOWN + + +async def test_workout_sensors_created_when_receive_workout_data( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test workout sensors will be added if we receive workout data.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("sensor.henk_last_workout_type") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("sensor.henk_last_workout_type") From 421832e09c110d56161e8eb854c2418e410c03f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 11:13:44 +0200 Subject: [PATCH 755/968] Remove unused test fixture from frontend tests (#102642) --- tests/components/frontend/test_init.py | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index f0c433f2e96..e6ef6518d1b 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,9 +8,6 @@ from unittest.mock import patch import pytest from homeassistant.components.frontend import ( - CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5, - CONF_JS_VERSION, CONF_THEMES, DEFAULT_THEME_COLOR, DOMAIN, @@ -106,23 +103,6 @@ async def ws_client(hass, hass_ws_client, frontend): return await hass_ws_client(hass) -@pytest.fixture -async def mock_http_client_with_urls(hass, aiohttp_client, ignore_frontend_deps): - """Start the Home Assistant HTTP component.""" - assert await async_setup_component( - hass, - "frontend", - { - DOMAIN: { - CONF_JS_VERSION: "auto", - CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], - CONF_EXTRA_HTML_URL_ES5: ["https://domain.com/my_extra_url_es5.html"], - } - }, - ) - return await aiohttp_client(hass.http.app) - - @pytest.fixture def mock_onboarded(): """Mock that we're onboarded.""" From e20d4abfe1676d1e91e0ee297e45ceb6d6d119a3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 11:31:16 +0200 Subject: [PATCH 756/968] Test extra javascript functionality in frontend (#102643) --- tests/components/frontend/test_init.py | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index e6ef6518d1b..0c6b893b4b9 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -8,6 +8,8 @@ from unittest.mock import patch import pytest from homeassistant.components.frontend import ( + CONF_EXTRA_JS_URL_ES5, + CONF_EXTRA_MODULE_URL, CONF_THEMES, DEFAULT_THEME_COLOR, DOMAIN, @@ -103,6 +105,22 @@ async def ws_client(hass, hass_ws_client, frontend): return await hass_ws_client(hass) +@pytest.fixture +async def mock_http_client_with_extra_js(hass, aiohttp_client, ignore_frontend_deps): + """Start the Home Assistant HTTP component.""" + assert await async_setup_component( + hass, + "frontend", + { + DOMAIN: { + CONF_EXTRA_MODULE_URL: ["/local/my_module.js"], + CONF_EXTRA_JS_URL_ES5: ["/local/my_es5.js"], + } + }, + ) + return await aiohttp_client(hass.http.app) + + @pytest.fixture def mock_onboarded(): """Mock that we're onboarded.""" @@ -356,6 +374,17 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} +async def test_extra_js(mock_http_client_with_extra_js, mock_onboarded): + """Test that extra javascript is loaded.""" + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' in text + assert '"/local/my_es5.js"' in text + + async def test_get_panels( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client ) -> None: From 8cfb8cb084ef58bde26733b9642dc055765612c7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 11:38:54 +0200 Subject: [PATCH 757/968] Add serial number to Blink (#102621) --- homeassistant/components/blink/alarm_control_panel.py | 2 +- homeassistant/components/blink/binary_sensor.py | 6 ++++-- homeassistant/components/blink/camera.py | 1 + homeassistant/components/blink/sensor.py | 6 ++++-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index c789d7cdd6f..d1fcb889fb8 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -60,12 +60,12 @@ class BlinkSyncModuleHA( self.api: Blink = coordinator.api self._coordinator = coordinator self.sync = sync - self._name: str = name self._attr_unique_id: str = sync.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, sync.serial)}, name=f"{DOMAIN} {name}", manufacturer=DEFAULT_BRAND, + serial_number=sync.serial, ) self._update_attr() diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py index 65e454e4434..47b45e2f4ec 100644 --- a/homeassistant/components/blink/binary_sensor.py +++ b/homeassistant/components/blink/binary_sensor.py @@ -72,9 +72,11 @@ class BlinkBinarySensor(CoordinatorEntity[BlinkUpdateCoordinator], BinarySensorE super().__init__(coordinator) self.entity_description = description self._camera = coordinator.api.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=camera, manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 4ff0ba86db9..31c4e4a563e 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -58,6 +58,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): self._attr_unique_id = f"{camera.serial}-camera" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, camera.serial)}, + serial_number=camera.serial, name=name, manufacturer=DEFAULT_BRAND, model=camera.camera_type, diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py index 9453d3b6d6b..064ad9d04f2 100644 --- a/homeassistant/components/blink/sensor.py +++ b/homeassistant/components/blink/sensor.py @@ -74,14 +74,16 @@ class BlinkSensor(CoordinatorEntity[BlinkUpdateCoordinator], SensorEntity): self.entity_description = description self._camera = coordinator.api.cameras[camera] - self._attr_unique_id = f"{self._camera.serial}-{description.key}" + serial = self._camera.serial + self._attr_unique_id = f"{serial}-{description.key}" self._sensor_key = ( "temperature_calibrated" if description.key == "temperature" else description.key ) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._camera.serial)}, + identifiers={(DOMAIN, serial)}, + serial_number=serial, name=f"{DOMAIN} {camera}", manufacturer=DEFAULT_BRAND, model=self._camera.camera_type, From fea15148a18c10ab4b62ed8a10e01426b5c1bb2c Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 24 Oct 2023 15:17:46 +0300 Subject: [PATCH 758/968] Remove scan_interval from transmission (#98858) Co-authored-by: Joost Lekkerkerker Co-authored-by: Franck Nijhof --- .../components/transmission/config_flow.py | 15 +-------------- .../components/transmission/coordinator.py | 9 ++------- homeassistant/components/transmission/sensor.py | 4 ++-- homeassistant/components/transmission/switch.py | 6 +++--- tests/components/transmission/test_config_flow.py | 7 ++++--- 5 files changed, 12 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index fac4e770a26..d16981add87 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -7,13 +7,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -25,7 +19,6 @@ from .const import ( DEFAULT_NAME, DEFAULT_ORDER, DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, DOMAIN, SUPPORTED_ORDER_MODES, ) @@ -147,12 +140,6 @@ class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): return self.async_create_entry(title="", data=user_input) options = { - vol.Optional( - CONF_SCAN_INTERVAL, - default=self.config_entry.options.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - ): int, vol.Optional( CONF_LIMIT, default=self.config_entry.options.get(CONF_LIMIT, DEFAULT_LIMIT), diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index a9cfc93eea0..9df509b9783 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -8,7 +8,7 @@ import transmission_rpc from transmission_rpc.session import SessionStats from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_SCAN_INTERVAL +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -48,14 +48,9 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): hass, name=f"{DOMAIN} - {self.host}", logger=_LOGGER, - update_interval=timedelta(seconds=self.scan_interval), + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), ) - @property - def scan_interval(self) -> float: - """Return scan interval.""" - return self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - @property def limit(self) -> int: """Return limit.""" diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py index 2bfa065c19b..c3ba418f885 100644 --- a/homeassistant/components/transmission/sensor.py +++ b/homeassistant/components/transmission/sensor.py @@ -36,7 +36,7 @@ async def async_setup_entry( config_entry.entry_id ] - dev = [ + entities = [ TransmissionSpeedSensor( coordinator, "download_speed", @@ -79,7 +79,7 @@ async def async_setup_entry( ), ] - async_add_entities(dev, True) + async_add_entities(entities) class TransmissionSensor( diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py index bf01b5a9cdc..6d236964987 100644 --- a/homeassistant/components/transmission/switch.py +++ b/homeassistant/components/transmission/switch.py @@ -27,11 +27,11 @@ async def async_setup_entry( config_entry.entry_id ] - dev = [] + entities = [] for switch_type, switch_name in SWITCH_TYPES.items(): - dev.append(TransmissionSwitch(switch_type, switch_name, coordinator)) + entities.append(TransmissionSwitch(switch_type, switch_name, coordinator)) - async_add_entities(dev, True) + async_add_entities(entities) class TransmissionSwitch( diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 1bfae98fb71..04f44d3b7e7 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -76,7 +76,7 @@ async def test_options(hass: HomeAssistant) -> None: entry = MockConfigEntry( domain=transmission.DOMAIN, data=MOCK_CONFIG_DATA, - options={"scan_interval": 120}, + options={"limit": 10, "order": "oldest_first"}, ) entry.add_to_hass(hass) @@ -93,11 +93,12 @@ async def test_options(hass: HomeAssistant) -> None: assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"scan_interval": 10} + result["flow_id"], user_input={"limit": 20} ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["scan_interval"] == 10 + assert result["data"]["limit"] == 20 + assert result["data"]["order"] == "oldest_first" async def test_error_on_wrong_credentials( From 46322a0f5988f2ee68918113e798bebd5e236993 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 14:19:19 +0200 Subject: [PATCH 759/968] Add improv_ble integration (#102129) Co-authored-by: Martin Hjelmare --- CODEOWNERS | 2 + .../components/improv_ble/__init__.py | 1 + .../components/improv_ble/config_flow.py | 387 ++++++++++ homeassistant/components/improv_ble/const.py | 3 + .../components/improv_ble/manifest.json | 17 + .../components/improv_ble/strings.json | 50 ++ homeassistant/generated/bluetooth.py | 5 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/improv_ble/__init__.py | 60 ++ tests/components/improv_ble/conftest.py | 8 + .../components/improv_ble/test_config_flow.py | 717 ++++++++++++++++++ 14 files changed, 1263 insertions(+) create mode 100644 homeassistant/components/improv_ble/__init__.py create mode 100644 homeassistant/components/improv_ble/config_flow.py create mode 100644 homeassistant/components/improv_ble/const.py create mode 100644 homeassistant/components/improv_ble/manifest.json create mode 100644 homeassistant/components/improv_ble/strings.json create mode 100644 tests/components/improv_ble/__init__.py create mode 100644 tests/components/improv_ble/conftest.py create mode 100644 tests/components/improv_ble/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index e967f4d65e4..5441fea97d1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -586,6 +586,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/improv_ble/ @emontnemery +/tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @zxdavb /homeassistant/components/influxdb/ @mdegat01 /tests/components/influxdb/ @mdegat01 diff --git a/homeassistant/components/improv_ble/__init__.py b/homeassistant/components/improv_ble/__init__.py new file mode 100644 index 00000000000..985684cb5b8 --- /dev/null +++ b/homeassistant/components/improv_ble/__init__.py @@ -0,0 +1 @@ +"""The Improv BLE integration.""" diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py new file mode 100644 index 00000000000..8776227fc53 --- /dev/null +++ b/homeassistant/components/improv_ble/config_flow.py @@ -0,0 +1,387 @@ +"""Config flow for Improv via BLE integration.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any, TypeVar + +from bleak import BleakError +from improv_ble_client import ( + SERVICE_DATA_UUID, + Error, + ImprovBLEClient, + ImprovServiceData, + State, + device_filter, + errors as improv_ble_errors, +) +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, + async_last_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_T = TypeVar("_T") + +STEP_PROVISION_SCHEMA = vol.Schema( + { + vol.Required("ssid"): str, + vol.Required("password"): str, + } +) + + +class AbortFlow(Exception): + """Raised when a flow should be aborted.""" + + def __init__(self, reason: str) -> None: + """Initialize.""" + self.reason = reason + + +@dataclass +class Credentials: + """Container for WiFi credentials.""" + + password: str + ssid: str + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Improv via BLE.""" + + VERSION = 1 + + _authorize_task: asyncio.Task | None = None + _can_identify: bool | None = None + _credentials: Credentials | None = None + _provision_result: FlowResult | None = None + _provision_task: asyncio.Task | None = None + _reauth_entry: config_entries.ConfigEntry | None = None + _unsub: Callable[[], None] | None = None + + def __init__(self) -> None: + """Initialize the config flow.""" + self._device: ImprovBLEClient | None = None + # Populated by user step + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + # Populated by bluetooth, reauth_confirm and user steps + self._discovery_info: BluetoothServiceInfoBleak | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the user step to pick discovered device.""" + errors: dict[str, str] = {} + + if user_input is not None: + address = user_input[CONF_ADDRESS] + await self.async_set_unique_id(address, raise_on_progress=False) + # Guard against the user selecting a device which has been configured by + # another flow. + self._abort_if_unique_id_configured() + self._discovery_info = self._discovered_devices[address] + return await self.async_step_start_improv() + + current_addresses = self._async_current_ids() + for discovery in async_discovered_service_info(self.hass): + if ( + discovery.address in current_addresses + or discovery.address in self._discovered_devices + or not device_filter(discovery.advertisement) + ): + continue + self._discovered_devices[discovery.address] = discovery + + if not self._discovered_devices: + return self.async_abort(reason="no_devices_found") + + data_schema = vol.Schema( + { + vol.Required(CONF_ADDRESS): vol.In( + { + service_info.address: ( + f"{service_info.name} ({service_info.address})" + ) + for service_info in self._discovered_devices.values() + } + ), + } + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def async_step_bluetooth( + self, discovery_info: BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the Bluetooth discovery step.""" + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + service_data = discovery_info.service_data + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): + _LOGGER.debug( + "Device is already provisioned: %s", improv_service_data.state + ) + return self.async_abort(reason="already_provisioned") + self._discovery_info = discovery_info + name = self._discovery_info.name or self._discovery_info.address + self.context["title_placeholders"] = {"name": name} + return await self.async_step_bluetooth_confirm() + + async def async_step_bluetooth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle bluetooth confirm step.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + if user_input is None: + name = self._discovery_info.name or self._discovery_info.address + return self.async_show_form( + step_id="bluetooth_confirm", + description_placeholders={"name": name}, + ) + + return await self.async_step_start_improv() + + async def async_step_start_improv( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start improv flow. + + If the device supports identification, show a menu, if it does not, + ask for WiFi credentials. + """ + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + discovery_info = self._discovery_info = async_last_service_info( + self.hass, self._discovery_info.address + ) + if not discovery_info: + return self.async_abort(reason="cannot_connect") + service_data = discovery_info.service_data + improv_service_data = ImprovServiceData.from_bytes( + service_data[SERVICE_DATA_UUID] + ) + if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): + _LOGGER.debug( + "Device is already provisioned: %s", improv_service_data.state + ) + return self.async_abort(reason="already_provisioned") + + if not self._device: + self._device = ImprovBLEClient(discovery_info.device) + device = self._device + + if self._can_identify is None: + try: + self._can_identify = await self._try_call(device.can_identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + if self._can_identify: + return await self.async_step_main_menu() + return await self.async_step_provision() + + async def async_step_main_menu(self, _: None = None) -> FlowResult: + """Show the main menu.""" + return self.async_show_menu( + step_id="main_menu", + menu_options=[ + "identify", + "provision", + ], + ) + + async def async_step_identify( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle identify step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None: + try: + await self._try_call(self._device.identify()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + return self.async_show_form(step_id="identify") + return await self.async_step_start_improv() + + async def async_step_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + if user_input is None and self._credentials is None: + return self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA + ) + if user_input is not None: + self._credentials = Credentials(user_input["password"], user_input["ssid"]) + + try: + need_authorization = await self._try_call(self._device.need_authorization()) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + _LOGGER.debug("Need authorization: %s", need_authorization) + if need_authorization: + return await self.async_step_authorize() + return await self.async_step_do_provision() + + async def async_step_do_provision( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Execute provisioning.""" + + async def _do_provision() -> None: + # mypy is not aware that we can't get here without having these set already + assert self._credentials is not None + assert self._device is not None + + errors = {} + try: + redirect_url = await self._try_call( + self._device.provision( + self._credentials.ssid, self._credentials.password, None + ) + ) + except AbortFlow as err: + self._provision_result = self.async_abort(reason=err.reason) + return + except improv_ble_errors.ProvisioningFailed as err: + if err.error == Error.NOT_AUTHORIZED: + _LOGGER.debug("Need authorization when calling provision") + self._provision_result = await self.async_step_authorize() + return + if err.error == Error.UNABLE_TO_CONNECT: + self._credentials = None + errors["base"] = "unable_to_connect" + else: + self._provision_result = self.async_abort(reason="unknown") + return + else: + _LOGGER.debug("Provision successful, redirect URL: %s", redirect_url) + # Abort all flows in progress with same unique ID + for flow in self._async_in_progress(include_uninitialized=True): + flow_unique_id = flow["context"].get("unique_id") + if ( + flow["flow_id"] != self.flow_id + and self.unique_id == flow_unique_id + ): + self.hass.config_entries.flow.async_abort(flow["flow_id"]) + if redirect_url: + self._provision_result = self.async_abort( + reason="provision_successful_url", + description_placeholders={"url": redirect_url}, + ) + return + self._provision_result = self.async_abort(reason="provision_successful") + return + self._provision_result = self.async_show_form( + step_id="provision", data_schema=STEP_PROVISION_SCHEMA, errors=errors + ) + return + + if not self._provision_task: + self._provision_task = self.hass.async_create_task( + self._resume_flow_when_done(_do_provision()) + ) + return self.async_show_progress( + step_id="do_provision", progress_action="provisioning" + ) + + await self._provision_task + self._provision_task = None + return self.async_show_progress_done(next_step_id="provision_done") + + async def async_step_provision_done( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show the result of the provision step.""" + # mypy is not aware that we can't get here without having these set already + assert self._provision_result is not None + + result = self._provision_result + self._provision_result = None + return result + + async def _resume_flow_when_done(self, awaitable: Awaitable) -> None: + try: + await awaitable + finally: + self.hass.async_create_task( + self.hass.config_entries.flow.async_configure(flow_id=self.flow_id) + ) + + async def async_step_authorize( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle authorize step.""" + # mypy is not aware that we can't get here without having these set already + assert self._device is not None + + _LOGGER.debug("Wait for authorization") + if not self._authorize_task: + authorized_event = asyncio.Event() + + def on_state_update(state: State) -> None: + _LOGGER.debug("State update: %s", state.name) + if state != State.AUTHORIZATION_REQUIRED: + authorized_event.set() + + try: + self._unsub = await self._try_call( + self._device.subscribe_state_updates(on_state_update) + ) + except AbortFlow as err: + return self.async_abort(reason=err.reason) + + self._authorize_task = self.hass.async_create_task( + self._resume_flow_when_done(authorized_event.wait()) + ) + return self.async_show_progress( + step_id="authorize", progress_action="authorize" + ) + + await self._authorize_task + self._authorize_task = None + if self._unsub: + self._unsub() + self._unsub = None + return self.async_show_progress_done(next_step_id="provision") + + @staticmethod + async def _try_call(func: Coroutine[Any, Any, _T]) -> _T: + """Call the library and abort flow on common errors.""" + try: + return await func + except BleakError as err: + _LOGGER.warning("BleakError", exc_info=err) + raise AbortFlow("cannot_connect") from err + except improv_ble_errors.CharacteristicMissingError as err: + _LOGGER.warning("CharacteristicMissing", exc_info=err) + raise AbortFlow("characteristic_missing") from err + except improv_ble_errors.CommandFailed: + raise + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + raise AbortFlow("unknown") from err diff --git a/homeassistant/components/improv_ble/const.py b/homeassistant/components/improv_ble/const.py new file mode 100644 index 00000000000..0641773a055 --- /dev/null +++ b/homeassistant/components/improv_ble/const.py @@ -0,0 +1,3 @@ +"""Constants for the Improv BLE integration.""" + +DOMAIN = "improv_ble" diff --git a/homeassistant/components/improv_ble/manifest.json b/homeassistant/components/improv_ble/manifest.json new file mode 100644 index 00000000000..201bd206490 --- /dev/null +++ b/homeassistant/components/improv_ble/manifest.json @@ -0,0 +1,17 @@ +{ + "domain": "improv_ble", + "name": "Improv via BLE", + "bluetooth": [ + { + "service_uuid": "00467768-6228-2272-4663-277478268000", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb" + } + ], + "codeowners": ["@emontnemery"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/improv_ble", + "integration_type": "device", + "iot_class": "local_polling", + "requirements": ["py-improv-ble-client==1.0.2"] +} diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json new file mode 100644 index 00000000000..48b13f6b782 --- /dev/null +++ b/homeassistant/components/improv_ble/strings.json @@ -0,0 +1,50 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "[%key:component::bluetooth::config::step::user::description%]", + "data": { + "address": "[%key:common::config_flow::data::device%]" + } + }, + "bluetooth_confirm": { + "description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]" + }, + "identify": { + "description": "The device is now identifying itself, for example by blinking or beeping." + }, + "main_menu": { + "description": "Choose next step.", + "menu_options": { + "identify": "Identify device", + "provision": "Connect device to a Wi-Fi network" + } + }, + "provision": { + "description": "Enter Wi-Fi credentials to connect the device to your network.", + "data": { + "password": "Password", + "ssid": "SSID" + } + } + }, + "progress": { + "authorize": "The device requires authorization, please press its authorization button or consult the device's manual for how to proceed.", + "provisioning": "The device is connecting to the Wi-Fi network." + }, + "error": { + "unable_to_connect": "The device could not connect to the Wi-Fi network. Check that the SSID and password are correct and try again." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_provisioned": "The device is already connected to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", + "provision_successful": "The device has successfully connected to the Wi-Fi network.", + "provision_successful_url": "The device has successfully connected to the Wi-Fi network.\n\nPlease visit {url} to finish setup.", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index c2b24b68d29..13700a4521c 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -217,6 +217,11 @@ BLUETOOTH: list[dict[str, bool | str | int | list[int]]] = [ "domain": "idasen_desk", "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", }, + { + "domain": "improv_ble", + "service_data_uuid": "00004677-0000-1000-8000-00805f9b34fb", + "service_uuid": "00467768-6228-2272-4663-277478268000", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fa83d93c87b..64806d8fb86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -218,6 +218,7 @@ FLOWS = { "idasen_desk", "ifttt", "imap", + "improv_ble", "inkbird", "insteon", "intellifire", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index fb42c7f0e8e..b89139d7447 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2614,6 +2614,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "improv_ble": { + "name": "Improv via BLE", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "incomfort": { "name": "Intergas InComfort/Intouch Lan2RF gateway", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index fa334ab6ea6..f7e9af4484c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1512,6 +1512,9 @@ py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.2 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c28a50a81ee..fce13c56860 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1157,6 +1157,9 @@ py-cpuinfo==9.0.0 # homeassistant.components.dormakaba_dkey py-dormakaba-dkey==1.0.5 +# homeassistant.components.improv_ble +py-improv-ble-client==1.0.2 + # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/tests/components/improv_ble/__init__.py b/tests/components/improv_ble/__init__.py new file mode 100644 index 00000000000..f1c83bbc0d7 --- /dev/null +++ b/tests/components/improv_ble/__init__.py @@ -0,0 +1,60 @@ +"""Tests for the Improv via BLE integration.""" + +from improv_ble_client import SERVICE_DATA_UUID, SERVICE_UUID + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak + +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x01\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +PROVISIONED_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="00123456", + address="AA:BB:CC:DD:EE:F0", + rssi=-60, + manufacturer_data={}, + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F0", name="00123456"), + advertisement=generate_advertisement_data( + service_uuids=[SERVICE_UUID], + service_data={SERVICE_DATA_UUID: b"\x04\x00\x00\x00\x00\x00"}, + ), + time=0, + connectable=True, +) + + +NOT_IMPROV_BLE_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not", + address="AA:BB:CC:DD:EE:F2", + rssi=-60, + manufacturer_data={ + 33: b"\x00\x00\xd1\xf0b;\xd8\x1dE\xd6\xba\xeeL\xdd]\xf5\xb2\xe9", + 21: b"\x061\x00Z\x8f\x93\xb2\xec\x85\x06\x00i\x00\x02\x02Q\xed\x1d\xf0", + }, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:F2", name="Aug"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) diff --git a/tests/components/improv_ble/conftest.py b/tests/components/improv_ble/conftest.py new file mode 100644 index 00000000000..ea548efeb15 --- /dev/null +++ b/tests/components/improv_ble/conftest.py @@ -0,0 +1,8 @@ +"""Improv via BLE test fixtures.""" + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py new file mode 100644 index 00000000000..60228b409c2 --- /dev/null +++ b/tests/components/improv_ble/test_config_flow.py @@ -0,0 +1,717 @@ +"""Test the Improv via BLE config flow.""" +from collections.abc import Callable +from unittest.mock import patch + +from bleak.exc import BleakError +from improv_ble_client import Error, State, errors as improv_ble_errors +import pytest + +from homeassistant import config_entries +from homeassistant.components.improv_ble.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType + +from . import ( + IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_INFO, + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, +) + +IMPROV_BLE = "homeassistant.components.improv_ble" + + +@pytest.mark.parametrize( + ("url", "abort_reason", "placeholders"), + [ + ("http://bla.local", "provision_successful_url", {"url": "http://bla.local"}), + (None, "provision_successful", None), + ], +) +async def test_user_step_success( + hass: HomeAssistant, + url: str | None, + abort_reason: str | None, + placeholders: dict[str, str] | None, +) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + await _test_common_success_wo_identify( + hass, + result, + IMPROV_BLE_DISCOVERY_INFO.address, + url, + abort_reason, + placeholders, + ) + + +async def test_user_step_success_authorize(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + await _test_common_success_wo_identify_w_authorize( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + return_value=[ + PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + NOT_IMPROV_BLE_DISCOVERY_INFO, + ], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + + +async def test_async_step_user_takes_precedence_over_discovery( + hass: HomeAssistant, +) -> None: + """Test manual setup takes precedence over discovery.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + with patch( + f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + return_value=[IMPROV_BLE_DISCOVERY_INFO], + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.FORM + + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + # Verify the discovery flow was aborted + assert not hass.config_entries.flow.async_progress(DOMAIN) + + +async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: + """Test bluetooth step when device is already provisioned.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_provisioned" + + +async def test_bluetooth_confirm_provisioned_device(hass: HomeAssistant) -> None: + """Test bluetooth confirm step when device is already provisioned.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_provisioned" + + +async def test_bluetooth_confirm_lost_device(hass: HomeAssistant) -> None: + """Test bluetooth confirm step when device can no longer be connected to.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_bluetooth_step_success(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def test_bluetooth_step_success_identify(hass: HomeAssistant) -> None: + """Test bluetooth step success path.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + await _test_common_success_with_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) + + +async def _test_common_success_with_identify( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify"): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "identify" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.MENU + assert result["menu_options"] == ["identify", "provision"] + assert result["step_id"] == "main_menu" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "provision"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success_wo_identify( + hass: HomeAssistant, + result: FlowResult, + address: str, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success(hass, result) + + +async def _test_common_success( + hass: HomeAssistant, + result: FlowResult, + url: str | None = None, + abort_reason: str = "provision_successful", + placeholders: dict[str, str] | None = None, +) -> None: + """Test bluetooth and user flow success paths.""" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value=url, + ) as mock_provision: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("description_placeholders") == placeholders + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == abort_reason + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def _test_common_success_wo_identify_w_authorize( + hass: HomeAssistant, result: FlowResult, address: str +) -> None: + """Test bluetooth and user flow success paths.""" + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] is None + + await _test_common_success_w_authorize(hass, result) + + +async def _test_common_success_w_authorize( + hass: HomeAssistant, result: FlowResult +) -> None: + """Test bluetooth and user flow success paths.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ) as mock_subscribe_state_updates: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + mock_subscribe_state_updates.assert_awaited_once() + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + return_value="http://blabla.local", + ) as mock_provision: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["description_placeholders"] == {"url": "http://blabla.local"} + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "provision_successful_url" + + mock_provision.assert_awaited_once_with("MyWIFI", "secret", None) + + +async def test_bluetooth_step_already_in_progress(hass: HomeAssistant) -> None: + """Test we can't start a flow for the same device twice.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_in_progress" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_identify_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.MENU + assert result["step_id"] == "main_menu" + + with patch(f"{IMPROV_BLE}.config_flow.ImprovBLEClient.identify", side_effect=exc): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "identify"}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", side_effect=exc + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + ), +) +async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=True, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +async def _test_provision_error(hass: HomeAssistant, exc) -> None: + """Test bluetooth flow with error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "bluetooth_confirm" + assert result["errors"] is None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_ADDRESS: IMPROV_BLE_DISCOVERY_INFO.address}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.need_authorization", + return_value=False, + ), patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.provision", + side_effect=exc, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"ssid": "MyWIFI", "password": "secret"} + ) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "provisioning" + assert result["step_id"] == "do_provision" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.SHOW_PROGRESS_DONE + assert result["step_id"] == "provision_done" + + return result["flow_id"] + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + (BleakError, "cannot_connect"), + (Exception, "unknown"), + (improv_ble_errors.CharacteristicMissingError, "characteristic_missing"), + (improv_ble_errors.ProvisioningFailed(Error.UNKNOWN_ERROR), "unknown"), + ), +) +async def test_provision_fails(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == error + + +@pytest.mark.parametrize( + ("exc", "error"), + ((improv_ble_errors.ProvisioningFailed(Error.NOT_AUTHORIZED), "unknown"),), +) +async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + + async def subscribe_state_updates( + state_callback: Callable[[State], None] + ) -> Callable[[], None]: + state_callback(State.AUTHORIZED) + return lambda: None + + with patch( + f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", + side_effect=subscribe_state_updates, + ), patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + flow_id = await _test_provision_error(hass, exc) + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.SHOW_PROGRESS + assert result["progress_action"] == "authorize" + assert result["step_id"] == "authorize" + + +@pytest.mark.parametrize( + ("exc", "error"), + ( + ( + improv_ble_errors.ProvisioningFailed(Error.UNABLE_TO_CONNECT), + "unable_to_connect", + ), + ), +) +async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: + """Test bluetooth flow with error.""" + with patch( + f"{IMPROV_BLE}.config_flow.async_last_service_info", + return_value=IMPROV_BLE_DISCOVERY_INFO, + ): + flow_id = await _test_provision_error(hass, exc) + + result = await hass.config_entries.flow.async_configure(flow_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "provision" + assert result["errors"] == {"base": error} From 97cc05d0b4900f87cdebdb57b003dd16113aac49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 14:47:58 +0200 Subject: [PATCH 760/968] Make it possible to restart core in safe mode (#102606) --- homeassistant/__main__.py | 5 +- homeassistant/bootstrap.py | 3 + homeassistant/components/frontend/__init__.py | 14 +++- .../components/homeassistant/__init__.py | 11 +++- .../components/homeassistant/strings.json | 8 ++- homeassistant/config.py | 23 +++++++ homeassistant/core.py | 4 ++ homeassistant/loader.py | 4 +- homeassistant/runner.py | 2 + tests/components/frontend/test_init.py | 14 +++- tests/components/homeassistant/test_init.py | 13 +++- tests/test_bootstrap.py | 66 +++++++++++++++++++ tests/test_config.py | 13 ++++ tests/test_core.py | 1 + 14 files changed, 170 insertions(+), 11 deletions(-) diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9acf46dbac6..4ea324878ec 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -185,7 +185,9 @@ def main() -> int: ensure_config_path(config_dir) # pylint: disable-next=import-outside-toplevel - from . import runner + from . import config, runner + + safe_mode = config.safe_mode_enabled(config_dir) runtime_conf = runner.RuntimeConfig( config_dir=config_dir, @@ -198,6 +200,7 @@ def main() -> int: recovery_mode=args.recovery_mode, debug=args.debug, open_ui=args.open_ui, + safe_mode=safe_mode, ) fault_file_name = os.path.join(config_dir, FAULT_LOG_FILENAME) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 89aa5c05d0d..098f970d55f 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -120,6 +120,7 @@ async def async_setup_hass( runtime_config.log_no_color, ) + hass.config.safe_mode = runtime_config.safe_mode hass.config.skip_pip = runtime_config.skip_pip hass.config.skip_pip_packages = runtime_config.skip_pip_packages if runtime_config.skip_pip or runtime_config.skip_pip_packages: @@ -197,6 +198,8 @@ async def async_setup_hass( {"recovery_mode": {}, "http": http_conf}, hass, ) + elif hass.config.safe_mode: + _LOGGER.info("Starting in safe mode") if runtime_config.open_ui: hass.add_job(open_hass_ui, hass) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e8a71d23adf..0225d723d20 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -593,7 +593,7 @@ class IndexView(web_urldispatcher.AbstractResource): async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] if not onboarding.async_is_onboarded(hass): return web.Response(status=302, headers={"location": "/onboarding.html"}) @@ -602,12 +602,20 @@ class IndexView(web_urldispatcher.AbstractResource): self.get_template ) + extra_modules: frozenset[str] + extra_js_es5: frozenset[str] + if hass.config.safe_mode: + extra_modules = frozenset() + extra_js_es5 = frozenset() + else: + extra_modules = hass.data[DATA_EXTRA_MODULE_URL].urls + extra_js_es5 = hass.data[DATA_EXTRA_JS_URL_ES5].urls return web.Response( text=_async_render_index_cached( template, theme_color=MANIFEST_JSON["theme_color"], - extra_modules=hass.data[DATA_EXTRA_MODULE_URL].urls, - extra_js_es5=hass.data[DATA_EXTRA_JS_URL_ES5].urls, + extra_modules=extra_modules, + extra_js_es5=extra_js_es5, ), content_type="text/html", ) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 5b26cb29ded..c978a7d4320 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -44,6 +44,7 @@ from .const import ( from .exposed_entities import ExposedEntities ATTR_ENTRY_ID = "entry_id" +ATTR_SAFE_MODE = "safe_mode" _LOGGER = logging.getLogger(__name__) SERVICE_RELOAD_CORE_CONFIG = "reload_core_config" @@ -63,7 +64,7 @@ SCHEMA_RELOAD_CONFIG_ENTRY = vol.All( ), cv.has_at_least_one_key(ATTR_ENTRY_ID, *cv.ENTITY_SERVICE_FIELDS), ) - +SCHEMA_RESTART = vol.Schema({vol.Optional(ATTR_SAFE_MODE, default=False): bool}) SHUTDOWN_SERVICES = (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART) @@ -193,6 +194,8 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no ) if call.service == SERVICE_HOMEASSISTANT_RESTART: + if call.data[ATTR_SAFE_MODE]: + await conf_util.async_enable_safe_mode(hass) stop_handler = hass.data[DATA_STOP_HANDLER] await stop_handler(hass, True) @@ -228,7 +231,11 @@ async def async_setup(hass: ha.HomeAssistant, config: ConfigType) -> bool: # no hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service ) async_register_admin_service( - hass, ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service + hass, + ha.DOMAIN, + SERVICE_HOMEASSISTANT_RESTART, + async_handle_core_service, + SCHEMA_RESTART, ) async_register_admin_service( hass, ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index a3435a8d1f5..26871522819 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -68,7 +68,13 @@ }, "restart": { "name": "[%key:common::action::restart%]", - "description": "Restarts Home Assistant." + "description": "Restarts Home Assistant.", + "fields": { + "safe_mode": { + "name": "Safe mode", + "description": "Disable custom integrations and custom cards." + } + } }, "set_location": { "name": "Set location", diff --git a/homeassistant/config.py b/homeassistant/config.py index 8d316eb773b..1b7e90996dc 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -88,6 +88,8 @@ INTEGRATION_LOAD_EXCEPTIONS = ( *LOAD_EXCEPTIONS, ) +SAFE_MODE_FILENAME = "safe-mode" + DEFAULT_CONFIG = f""" # Loads default set of integrations. Do not remove. default_config: @@ -1007,3 +1009,24 @@ def async_notify_setup_error( persistent_notification.async_create( hass, message, "Invalid config", "invalid_config" ) + + +def safe_mode_enabled(config_dir: str) -> bool: + """Return if safe mode is enabled. + + If safe mode is enabled, the safe mode file will be removed. + """ + safe_mode_path = os.path.join(config_dir, SAFE_MODE_FILENAME) + safe_mode = os.path.exists(safe_mode_path) + if safe_mode: + os.remove(safe_mode_path) + return safe_mode + + +async def async_enable_safe_mode(hass: HomeAssistant) -> None: + """Enable safe mode.""" + + def _enable_safe_mode() -> None: + Path(hass.config.path(SAFE_MODE_FILENAME)).touch() + + await hass.async_add_executor_job(_enable_safe_mode) diff --git a/homeassistant/core.py b/homeassistant/core.py index e495973440e..2025d813be4 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2135,6 +2135,9 @@ class Config: # Use legacy template behavior self.legacy_templates: bool = False + # If Home Assistant is running in safe mode + self.safe_mode: bool = False + def distance(self, lat: float, lon: float) -> float | None: """Calculate distance from Home Assistant. @@ -2215,6 +2218,7 @@ class Config: "currency": self.currency, "country": self.country, "language": self.language, + "safe_mode": self.safe_mode, } def set_time_zone(self, time_zone_str: str) -> None: diff --git a/homeassistant/loader.py b/homeassistant/loader.py index e4f36f11a36..39564846de3 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -188,7 +188,7 @@ async def _async_get_custom_components( hass: HomeAssistant, ) -> dict[str, Integration]: """Return list of custom integrations.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return {} try: @@ -1179,7 +1179,7 @@ def _async_mount_config_dir(hass: HomeAssistant) -> None: def _lookup_path(hass: HomeAssistant) -> list[str]: """Return the lookup paths for legacy lookups.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ca658c154a2..622e69ecf8c 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -54,6 +54,8 @@ class RuntimeConfig: debug: bool = False open_ui: bool = False + safe_mode: bool = False + def can_use_pidfd() -> bool: """Check if pidfd_open is available. diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 0c6b893b4b9..4f75bc2e790 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -374,7 +374,9 @@ async def test_missing_themes(hass: HomeAssistant, ws_client) -> None: assert msg["result"]["themes"] == {} -async def test_extra_js(mock_http_client_with_extra_js, mock_onboarded): +async def test_extra_js( + hass: HomeAssistant, mock_http_client_with_extra_js, mock_onboarded +): """Test that extra javascript is loaded.""" resp = await mock_http_client_with_extra_js.get("") assert resp.status == 200 @@ -384,6 +386,16 @@ async def test_extra_js(mock_http_client_with_extra_js, mock_onboarded): assert '"/local/my_module.js"' in text assert '"/local/my_es5.js"' in text + # safe mode + hass.config.safe_mode = True + resp = await mock_http_client_with_extra_js.get("") + assert resp.status == 200 + assert "cache-control" not in resp.headers + + text = await resp.text() + assert '"/local/my_module.js"' not in text + assert '"/local/my_es5.js"' not in text + async def test_get_panels( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, mock_http_client diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 9048e03ea70..22b380a3249 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -11,6 +11,7 @@ from homeassistant import config import homeassistant.components as comps from homeassistant.components.homeassistant import ( ATTR_ENTRY_ID, + ATTR_SAFE_MODE, SERVICE_CHECK_CONFIG, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, @@ -536,22 +537,32 @@ async def test_raises_when_config_is_invalid( assert mock_async_check_ha_config_file.called -async def test_restart_homeassistant(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("service_data", "safe_mode_enabled"), + [({}, False), ({ATTR_SAFE_MODE: False}, False), ({ATTR_SAFE_MODE: True}, True)], +) +async def test_restart_homeassistant( + hass: HomeAssistant, service_data: dict, safe_mode_enabled: bool +) -> None: """Test we can restart when there is no configuration error.""" await async_setup_component(hass, "homeassistant", {}) with patch( "homeassistant.config.async_check_ha_config_file", return_value=None ) as mock_check, patch( + "homeassistant.config.async_enable_safe_mode" + ) as mock_safe_mode, patch( "homeassistant.core.HomeAssistant.async_stop", return_value=None ) as mock_restart: await hass.services.async_call( "homeassistant", SERVICE_HOMEASSISTANT_RESTART, + service_data, blocking=True, ) assert mock_check.called await hass.async_block_till_done() assert mock_restart.called + assert mock_safe_mode.called == safe_mode_enabled async def test_stop_homeassistant(hass: HomeAssistant) -> None: diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index d7901b0566e..555bcbdf6b2 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -642,6 +642,72 @@ async def test_setup_hass_recovery_mode( assert len(browser_setup.mock_calls) == 0 +async def test_setup_hass_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=False, + safe_mode=True, + ), + ) + + assert "recovery_mode" not in hass.config.components + assert "Starting in recovery mode" not in caplog.text + assert "Starting in safe mode" in caplog.text + + +async def test_setup_hass_recovery_mode_and_safe_mode( + mock_hass_config: None, + mock_enable_logging: Mock, + mock_is_virtual_env: Mock, + mock_mount_local_lib_path: AsyncMock, + mock_ensure_config_exists: AsyncMock, + mock_process_ha_config_upgrade: Mock, + caplog: pytest.LogCaptureFixture, + event_loop: asyncio.AbstractEventLoop, +) -> None: + """Test it works.""" + with patch("homeassistant.components.browser.setup"), patch( + "homeassistant.config_entries.ConfigEntries.async_domains", + return_value=["browser"], + ): + hass = await bootstrap.async_setup_hass( + runner.RuntimeConfig( + config_dir=get_test_config_dir(), + verbose=False, + log_rotate_days=10, + log_file="", + log_no_color=False, + skip_pip=True, + recovery_mode=True, + safe_mode=True, + ), + ) + + assert "recovery_mode" in hass.config.components + assert "Starting in recovery mode" in caplog.text + assert "Starting in safe mode" not in caplog.text + + @pytest.mark.parametrize("hass_config", [{"homeassistant": {"non-existing": 1}}]) async def test_setup_hass_invalid_core_config( mock_hass_config: None, diff --git a/tests/test_config.py b/tests/test_config.py index aeb25313302..d5181bbe115 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -49,6 +49,7 @@ VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE) AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, config_util.AUTOMATION_CONFIG_PATH) SCRIPTS_PATH = os.path.join(CONFIG_DIR, config_util.SCRIPT_CONFIG_PATH) SCENES_PATH = os.path.join(CONFIG_DIR, config_util.SCENE_CONFIG_PATH) +SAFE_MODE_PATH = os.path.join(CONFIG_DIR, config_util.SAFE_MODE_FILENAME) def create_file(path): @@ -80,6 +81,9 @@ def teardown(): if os.path.isfile(SCENES_PATH): os.remove(SCENES_PATH) + if os.path.isfile(SAFE_MODE_PATH): + os.remove(SAFE_MODE_PATH) + async def test_create_default_config(hass: HomeAssistant) -> None: """Test creation of default config.""" @@ -1386,3 +1390,12 @@ async def test_core_store_no_country( await hass.config.async_update(**{"country": "SE"}) issue = issue_registry.async_get_issue("homeassistant", issue_id) assert not issue + + +async def test_safe_mode(hass: HomeAssistant) -> None: + """Test safe mode.""" + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + assert config_util.safe_mode_enabled(hass.config.config_dir) is False + await config_util.async_enable_safe_mode(hass) + assert config_util.safe_mode_enabled(hass.config.config_dir) is True + assert config_util.safe_mode_enabled(hass.config.config_dir) is False diff --git a/tests/test_core.py b/tests/test_core.py index cd855ab2c73..957da634dce 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1494,6 +1494,7 @@ async def test_config_as_dict() -> None: "currency": "EUR", "country": None, "language": "en", + "safe_mode": False, } assert expected == config.as_dict() From 4604c5a152581b2c2b49db4235e0bb167b841502 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 15:24:30 +0200 Subject: [PATCH 761/968] Allow connecting an Improv via BLE device to a public network (#102655) --- homeassistant/components/improv_ble/config_flow.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 8776227fc53..0ed2becc1f1 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -37,7 +37,7 @@ _T = TypeVar("_T") STEP_PROVISION_SCHEMA = vol.Schema( { vol.Required("ssid"): str, - vol.Required("password"): str, + vol.Optional("password"): str, } ) @@ -236,7 +236,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="provision", data_schema=STEP_PROVISION_SCHEMA ) if user_input is not None: - self._credentials = Credentials(user_input["password"], user_input["ssid"]) + self._credentials = Credentials( + user_input.get("password", ""), user_input["ssid"] + ) try: need_authorization = await self._try_call(self._device.need_authorization()) From 80b3fec6753c275f056a47187ebad92ebd5cd8fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Oct 2023 08:35:59 -0500 Subject: [PATCH 762/968] Bump aioesphomeapi to 18.0.12 (#102626) changelog: https://github.com/esphome/aioesphomeapi/compare/v18.0.11...v18.0.12 --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 4cade907899..5e6d56b6ca2 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.11", + "aioesphomeapi==18.0.12", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index f7e9af4484c..77126a6745a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.11 +aioesphomeapi==18.0.12 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fce13c56860..c99ee75b9b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.11 +aioesphomeapi==18.0.12 # homeassistant.components.flo aioflo==2021.11.0 From 9600c7fac1486d2ce3262ece5c1c43f9aee400d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 24 Oct 2023 16:38:11 +0200 Subject: [PATCH 763/968] Add workout calendar to Withings (#102589) --- homeassistant/components/withings/__init__.py | 4 +- homeassistant/components/withings/calendar.py | 104 +++++++++++ .../components/withings/strings.json | 5 + .../withings/snapshots/test_calendar.ambr | 167 ++++++++++++++++++ tests/components/withings/test_calendar.py | 85 +++++++++ 5 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/withings/calendar.py create mode 100644 tests/components/withings/snapshots/test_calendar.ambr create mode 100644 tests/components/withings/test_calendar.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 2158b169844..496aba290ba 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -62,7 +62,7 @@ from .coordinator import ( WithingsWorkoutDataUpdateCoordinator, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] CONFIG_SCHEMA = vol.Schema( { @@ -129,6 +129,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: class WithingsData: """Dataclass to hold withings domain data.""" + client: WithingsClient measurement_coordinator: WithingsMeasurementDataUpdateCoordinator sleep_coordinator: WithingsSleepDataUpdateCoordinator bed_presence_coordinator: WithingsBedPresenceDataUpdateCoordinator @@ -174,6 +175,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: client.refresh_token_function = _refresh_token withings_data = WithingsData( + client=client, measurement_coordinator=WithingsMeasurementDataUpdateCoordinator(hass, client), sleep_coordinator=WithingsSleepDataUpdateCoordinator(hass, client), bed_presence_coordinator=WithingsBedPresenceDataUpdateCoordinator(hass, client), diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py new file mode 100644 index 00000000000..3ee2c7dae59 --- /dev/null +++ b/homeassistant/components/withings/calendar.py @@ -0,0 +1,104 @@ +"""Calendar platform for Withings.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime + +from aiowithings import WithingsClient, WorkoutCategory + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er + +from . import DOMAIN, WithingsData +from .coordinator import WithingsWorkoutDataUpdateCoordinator +from .entity import WithingsEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the calendar platform for entity.""" + ent_reg = er.async_get(hass) + withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + + workout_coordinator = withings_data.workout_coordinator + + calendar_setup_before = ent_reg.async_get_entity_id( + Platform.CALENDAR, + DOMAIN, + f"withings_{entry.unique_id}_workout", + ) + + if workout_coordinator.data is not None or calendar_setup_before: + async_add_entities( + [WithingsWorkoutCalendarEntity(withings_data.client, workout_coordinator)], + ) + else: + remove_calendar_listener: Callable[[], None] + + def _async_add_calendar_entity() -> None: + """Add calendar entity.""" + if workout_coordinator.data is not None: + async_add_entities( + [ + WithingsWorkoutCalendarEntity( + withings_data.client, workout_coordinator + ) + ], + ) + remove_calendar_listener() + + remove_calendar_listener = workout_coordinator.async_add_listener( + _async_add_calendar_entity + ) + + +def get_event_name(category: WorkoutCategory) -> str: + """Return human-readable category.""" + name = category.name.lower().capitalize() + return name.replace("_", " ") + + +class WithingsWorkoutCalendarEntity(CalendarEntity, WithingsEntity): + """A calendar entity.""" + + _attr_translation_key = "workout" + + coordinator: WithingsWorkoutDataUpdateCoordinator + + def __init__( + self, client: WithingsClient, coordinator: WithingsWorkoutDataUpdateCoordinator + ) -> None: + """Create the Calendar entity.""" + super().__init__(coordinator, "workout") + self.client = client + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return None + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + workouts = await self.client.get_workouts_in_period( + start_date.date(), end_date.date() + ) + event_list = [] + for workout in workouts: + event = CalendarEvent( + start=workout.start_date, + end=workout.end_date, + summary=get_event_name(workout.category), + ) + + event_list.append(event) + + return event_list diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index dcb63f22a2e..fb447f3578e 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -29,6 +29,11 @@ "name": "In bed" } }, + "calendar": { + "workout": { + "name": "Workouts" + } + }, "sensor": { "fat_mass": { "name": "Fat mass" diff --git a/tests/components/withings/snapshots/test_calendar.ambr b/tests/components/withings/snapshots/test_calendar.ambr new file mode 100644 index 00000000000..045b4216a2f --- /dev/null +++ b/tests/components/withings/snapshots/test_calendar.ambr @@ -0,0 +1,167 @@ +# serializer version: 1 +# name: test_api_calendar + list([ + dict({ + 'entity_id': 'calendar.henk_workouts', + 'name': 'henk Workouts', + }), + ]) +# --- +# name: test_api_events + list([ + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-29T12:15:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-29T12:06:51-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-31T01:18:44-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-31T01:08:27-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-08-04T09:15:19-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-08-04T09:00:39-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:51:01-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:33:55-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:31:46-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T11:20:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-22T16:58:13-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-22T16:55:53-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-09-14T11:15:27-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-09-14T10:42:31-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T00:16:07-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T00:12:49-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:43:58-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:39:43-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + dict({ + 'description': None, + 'end': dict({ + 'dateTime': '2023-10-09T02:17:12-07:00', + }), + 'location': None, + 'recurrence_id': None, + 'rrule': None, + 'start': dict({ + 'dateTime': '2023-10-09T02:13:23-07:00', + }), + 'summary': 'Walk', + 'uid': None, + }), + ]) +# --- diff --git a/tests/components/withings/test_calendar.py b/tests/components/withings/test_calendar.py new file mode 100644 index 00000000000..227f65473fc --- /dev/null +++ b/tests/components/withings/test_calendar.py @@ -0,0 +1,85 @@ +"""Tests for the Withings calendar.""" +from datetime import date, timedelta +from http import HTTPStatus +from unittest.mock import AsyncMock + +from freezegun.api import FrozenDateTimeFactory +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import load_workout_fixture + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.withings import setup_integration +from tests.typing import ClientSessionGenerator + + +async def test_api_calendar( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the API returns the calendar.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get("/api/calendars") + assert response.status == HTTPStatus.OK + data = await response.json() + assert data == snapshot + + +async def test_api_events( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test the Withings calendar view.""" + await setup_integration(hass, polling_config_entry, False) + + client = await hass_client() + response = await client.get( + "/api/calendars/calendar.henk_workouts?start=2023-08-01&end=2023-11-01" + ) + assert withings.get_workouts_in_period.called == 1 + assert withings.get_workouts_in_period.call_args_list[1].args == ( + date(2023, 8, 1), + date(2023, 11, 1), + ) + assert response.status == HTTPStatus.OK + events = await response.json() + assert events == snapshot + + +async def test_calendar_created_when_workouts_available( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the calendar is only created when workouts are available.""" + withings.get_workouts_in_period.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert hass.states.get("calendar.henk_workouts") is None + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") is None + + withings.get_workouts_in_period.return_value = load_workout_fixture() + + freezer.tick(timedelta(minutes=10)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert hass.states.get("calendar.henk_workouts") From 8c3ae1b30cd2cbce1bae176128b670accee67809 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 24 Oct 2023 17:53:55 +0200 Subject: [PATCH 764/968] Add hvac_modes property to Plugwise (#102636) Co-authored-by: Franck Nijhof --- homeassistant/components/plugwise/climate.py | 19 +++-- tests/components/plugwise/test_climate.py | 84 +++++++------------- 2 files changed, 41 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index 32146c2753f..e25c2e72e05 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -66,13 +66,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = presets - # Determine hvac modes and current hvac mode - self._attr_hvac_modes = [HVACMode.HEAT] - if self.coordinator.data.gateway["cooling_present"]: - self._attr_hvac_modes = [HVACMode.HEAT_COOL] - if self.device["available_schedules"] != ["None"]: - self._attr_hvac_modes.append(HVACMode.AUTO) - self._attr_min_temp = self.device["thermostat"]["lower_bound"] self._attr_max_temp = self.device["thermostat"]["upper_bound"] # Ensure we don't drop below 0.1 @@ -117,6 +110,18 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): return HVACMode.HEAT return HVACMode(mode) + @property + def hvac_modes(self) -> list[HVACMode]: + """Return the list of available HVACModes.""" + hvac_modes = [HVACMode.HEAT] + if self.coordinator.data.gateway["cooling_present"]: + hvac_modes = [HVACMode.HEAT_COOL] + + if self.device["available_schedules"] != ["None"]: + hvac_modes.append(HVACMode.AUTO) + + return hvac_modes + @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 496eeaae084..11425cf79da 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -1,14 +1,17 @@ """Tests for the Plugwise Climate integration.""" -from unittest.mock import MagicMock + +from datetime import timedelta +from unittest.mock import MagicMock, patch from plugwise.exceptions import PlugwiseError import pytest -from homeassistant.components.climate import HVACMode +from homeassistant.components.climate.const import HVACMode from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_adam_climate_entity_attributes( @@ -87,8 +90,6 @@ async def test_adam_climate_adjust_negative_testing( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry ) -> None: """Test exceptions of climate entities.""" - mock_smile_adam.set_preset.side_effect = PlugwiseError - mock_smile_adam.set_schedule_state.side_effect = PlugwiseError mock_smile_adam.set_temperature.side_effect = PlugwiseError with pytest.raises(HomeAssistantError): @@ -99,25 +100,6 @@ async def test_adam_climate_adjust_negative_testing( blocking=True, ) - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, - blocking=True, - ) - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": "climate.zone_thermostat_jessie", - "hvac_mode": HVACMode.AUTO, - }, - blocking=True, - ) - async def test_adam_climate_entity_climate_changes( hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry @@ -129,7 +111,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "temperature": 25}, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 1 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -145,7 +126,6 @@ async def test_adam_climate_entity_climate_changes( }, blocking=True, ) - assert mock_smile_adam.set_temperature.call_count == 2 mock_smile_adam.set_temperature.assert_called_with( "c50f167537524366a5af7aa3942feb1e", {"setpoint": 25.0} @@ -165,7 +145,6 @@ async def test_adam_climate_entity_climate_changes( {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_adam.set_preset.call_count == 1 mock_smile_adam.set_preset.assert_called_with( "c50f167537524366a5af7aa3942feb1e", "away" @@ -173,26 +152,13 @@ async def test_adam_climate_entity_climate_changes( await hass.services.async_call( "climate", - "set_temperature", - {"entity_id": "climate.zone_thermostat_jessie", "temperature": 25}, + "set_hvac_mode", + {"entity_id": "climate.zone_lisa_wk", "hvac_mode": "heat"}, blocking=True, ) - - assert mock_smile_adam.set_temperature.call_count == 3 - mock_smile_adam.set_temperature.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", {"setpoint": 25.0} - ) - - await hass.services.async_call( - "climate", - "set_preset_mode", - {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"}, - blocking=True, - ) - - assert mock_smile_adam.set_preset.call_count == 2 - mock_smile_adam.set_preset.assert_called_with( - "82fa13f017d240daa0d0ea1775420f24", "home" + assert mock_smile_adam.set_schedule_state.call_count == 2 + mock_smile_adam.set_schedule_state.assert_called_with( + "c50f167537524366a5af7aa3942feb1e", "GF7 Woonkamer", "off" ) with pytest.raises(HomeAssistantError): @@ -270,7 +236,9 @@ async def test_anna_3_climate_entity_attributes( async def test_anna_climate_entity_climate_changes( - hass: HomeAssistant, mock_smile_anna: MagicMock, init_integration: MockConfigEntry + hass: HomeAssistant, + mock_smile_anna: MagicMock, + init_integration: MockConfigEntry, ) -> None: """Test handling of user requests in anna climate device environment.""" await hass.services.async_call( @@ -279,7 +247,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "target_temp_high": 25, "target_temp_low": 20}, blocking=True, ) - assert mock_smile_anna.set_temperature.call_count == 1 mock_smile_anna.set_temperature.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", @@ -292,7 +259,6 @@ async def test_anna_climate_entity_climate_changes( {"entity_id": "climate.anna", "preset_mode": "away"}, blocking=True, ) - assert mock_smile_anna.set_preset.call_count == 1 mock_smile_anna.set_preset.assert_called_with( "c784ee9fdab44e1395b8dee7d7a497d5", "away" @@ -301,24 +267,32 @@ async def test_anna_climate_entity_climate_changes( await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "heat"}, + {"entity_id": "climate.anna", "hvac_mode": "auto"}, blocking=True, ) - - assert mock_smile_anna.set_temperature.call_count == 1 assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "on" ) await hass.services.async_call( "climate", "set_hvac_mode", - {"entity_id": "climate.anna", "hvac_mode": "auto"}, + {"entity_id": "climate.anna", "hvac_mode": "heat"}, blocking=True, ) - assert mock_smile_anna.set_schedule_state.call_count == 2 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "on" + "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "off" ) + data = mock_smile_anna.async_update.return_value + data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] + with patch( + "homeassistant.components.plugwise.coordinator.Smile.async_update", + return_value=data, + ): + async_fire_time_changed(hass, utcnow() + timedelta(minutes=1)) + await hass.async_block_till_done() + state = hass.states.get("climate.anna") + assert state.state == HVACMode.HEAT + assert state.attributes["hvac_modes"] == [HVACMode.HEAT] From 508cffd1b51a10654d43ebb2eb5e4bb2e017578c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 18:05:55 +0200 Subject: [PATCH 765/968] Bump py-improv-ble-client to 1.0.3 (#102661) --- homeassistant/components/improv_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/improv_ble/manifest.json b/homeassistant/components/improv_ble/manifest.json index 201bd206490..30af6e111a0 100644 --- a/homeassistant/components/improv_ble/manifest.json +++ b/homeassistant/components/improv_ble/manifest.json @@ -13,5 +13,5 @@ "documentation": "https://www.home-assistant.io/integrations/improv_ble", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["py-improv-ble-client==1.0.2"] + "requirements": ["py-improv-ble-client==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 77126a6745a..3aee4caf853 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1513,7 +1513,7 @@ py-cpuinfo==9.0.0 py-dormakaba-dkey==1.0.5 # homeassistant.components.improv_ble -py-improv-ble-client==1.0.2 +py-improv-ble-client==1.0.3 # homeassistant.components.melissa py-melissa-climate==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c99ee75b9b2..4786cc77013 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1158,7 +1158,7 @@ py-cpuinfo==9.0.0 py-dormakaba-dkey==1.0.5 # homeassistant.components.improv_ble -py-improv-ble-client==1.0.2 +py-improv-ble-client==1.0.3 # homeassistant.components.melissa py-melissa-climate==2.1.4 From f733f20834412d33d9d2b0a724b6ec45a4759f90 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 20:39:16 +0200 Subject: [PATCH 766/968] Use real devices in arcam_fmj device trigger tests (#102677) --- tests/components/arcam_fmj/test_device_trigger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 7caba687ff2..d073e9c75da 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -82,7 +82,7 @@ async def test_if_fires_on_turn_on_request( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -128,7 +128,7 @@ async def test_if_fires_on_turn_on_request_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, From 4febb2e1d3e35696c6bccf32041f8a11118fad30 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Oct 2023 19:14:17 +0000 Subject: [PATCH 767/968] Bump `nam` to version 2.2.0 (#102673) --- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index 32f7329a0a2..be571460b4a 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["nettigo_air_monitor"], "quality_scale": "platinum", - "requirements": ["nettigo-air-monitor==2.1.0"], + "requirements": ["nettigo-air-monitor==2.2.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 3aee4caf853..903198fcf75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1276,7 +1276,7 @@ netdata==1.1.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4786cc77013..1ca6129ce29 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -996,7 +996,7 @@ nessclient==1.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==2.1.0 +nettigo-air-monitor==2.2.0 # homeassistant.components.nexia nexia==2.0.7 From d25b4aae14906c5dd73ca7906497230910989973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Bed=C5=99ich?= Date: Tue, 24 Oct 2023 21:40:41 +0200 Subject: [PATCH 768/968] Add ZHA cover tilt (#102072) * cover tilt reimplementation * rework tilt test * Fix ZHA cover tests * Match ZHA cover tilt code-style with the rest * Increase coverage for ZHA cover, optimize update --------- Co-authored-by: josef109 --- .../zha/core/cluster_handlers/closures.py | 25 ++- homeassistant/components/zha/cover.py | 68 ++++-- tests/components/zha/test_cover.py | 211 +++++++++++++++++- 3 files changed, 270 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/zha/core/cluster_handlers/closures.py b/homeassistant/components/zha/core/cluster_handlers/closures.py index 4262a16800d..980a6f88a75 100644 --- a/homeassistant/components/zha/core/cluster_handlers/closures.py +++ b/homeassistant/components/zha/core/cluster_handlers/closures.py @@ -124,11 +124,19 @@ class WindowCoveringClient(ClientClusterHandler): class WindowCovering(ClusterHandler): """Window cluster handler.""" - _value_attribute = 8 + _value_attribute_lift = ( + closures.WindowCovering.AttributeDefs.current_position_lift_percentage.id + ) + _value_attribute_tilt = ( + closures.WindowCovering.AttributeDefs.current_position_tilt_percentage.id + ) REPORT_CONFIG = ( AttrReportConfig( attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE ), + AttrReportConfig( + attr="current_position_tilt_percentage", config=REPORT_CONFIG_IMMEDIATE + ), ) async def async_update(self): @@ -140,10 +148,21 @@ class WindowCovering(ClusterHandler): if result is not None: self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - 8, + self._value_attribute_lift, "current_position_lift_percentage", result, ) + result = await self.get_attribute_value( + "current_position_tilt_percentage", from_cache=False + ) + self.debug("read current tilt position: %s", result) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self._value_attribute_tilt, + "current_position_tilt_percentage", + result, + ) @callback def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: @@ -152,7 +171,7 @@ class WindowCovering(ClusterHandler): self.debug( "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) - if attrid == self._value_attribute: + if attrid in (self._value_attribute_lift, self._value_attribute_tilt): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value ) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index d142aa2726b..f36cbc13533 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -11,6 +11,7 @@ from zigpy.zcl.foundation import Status from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, ) @@ -80,6 +81,7 @@ class ZhaCover(ZhaEntity, CoverEntity): super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) self._cover_cluster_handler = self.cluster_handlers.get(CLUSTER_HANDLER_COVER) self._current_position = None + self._tilt_position = None async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" @@ -94,6 +96,10 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = last_state.state if "current_position" in last_state.attributes: self._current_position = last_state.attributes["current_position"] + if "current_tilt_position" in last_state.attributes: + self._tilt_position = last_state.attributes[ + "current_tilt_position" + ] # first allocation activate tilt @property def is_closed(self) -> bool | None: @@ -120,11 +126,20 @@ class ZhaCover(ZhaEntity, CoverEntity): """ return self._current_position + @property + def current_cover_tilt_position(self) -> int | None: + """Return the current tilt position of the cover.""" + return self._tilt_position + @callback def async_set_position(self, attr_id, attr_name, value): """Handle position update from cluster handler.""" - _LOGGER.debug("setting position: %s", value) - self._current_position = 100 - value + _LOGGER.debug("setting position: %s %s %s", attr_id, attr_name, value) + if attr_name == "current_position_lift_percentage": + self._current_position = 100 - value + elif attr_name == "current_position_tilt_percentage": + self._tilt_position = 100 - value + if self._current_position == 0: self._state = STATE_CLOSED elif self._current_position == 100: @@ -145,6 +160,13 @@ class ZhaCover(ZhaEntity, CoverEntity): raise HomeAssistantError(f"Failed to open cover: {res[1]}") self.async_update_state(STATE_OPENING) + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(0) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to open cover tilt: {res[1]}") + self.async_update_state(STATE_OPENING) + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_cluster_handler.down_close() @@ -152,6 +174,13 @@ class ZhaCover(ZhaEntity, CoverEntity): raise HomeAssistantError(f"Failed to close cover: {res[1]}") self.async_update_state(STATE_CLOSING) + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + res = await self._cover_cluster_handler.go_to_tilt_percentage(100) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to close cover tilt: {res[1]}") + self.async_update_state(STATE_CLOSING) + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] @@ -162,6 +191,16 @@ class ZhaCover(ZhaEntity, CoverEntity): STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover til to a specific position.""" + new_pos = kwargs[ATTR_TILT_POSITION] + res = await self._cover_cluster_handler.go_to_tilt_percentage(100 - new_pos) + if res[1] is not Status.SUCCESS: + raise HomeAssistantError(f"Failed to set cover tilt position: {res[1]}") + self.async_update_state( + STATE_CLOSING if new_pos < self._tilt_position else STATE_OPENING + ) + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_cluster_handler.stop() @@ -170,28 +209,9 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() - async def async_update(self) -> None: - """Attempt to retrieve the open/close state of the cover.""" - await super().async_update() - await self.async_get_state() - - async def async_get_state(self, from_cache=True): - """Fetch the current state.""" - _LOGGER.debug("polling current state") - if self._cover_cluster_handler: - pos = await self._cover_cluster_handler.get_attribute_value( - "current_position_lift_percentage", from_cache=from_cache - ) - _LOGGER.debug("read pos=%s", pos) - - if pos is not None: - self._current_position = 100 - pos - self._state = ( - STATE_OPEN if self.current_cover_position > 0 else STATE_CLOSED - ) - else: - self._current_position = None - self._state = None + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + await self.async_stop_cover() @MULTI_MATCH( diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 08f84613ff3..0adb7583d31 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -11,11 +11,18 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, + ATTR_TILT_POSITION, DOMAIN as COVER_DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, + SERVICE_TOGGLE_COVER_TILT, ) from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( @@ -27,6 +34,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_component import async_update_entity from .common import ( async_enable_traffic, @@ -64,7 +72,7 @@ def zigpy_cover_device(zigpy_device_mock): endpoints = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE, + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.WINDOW_COVERING_DEVICE, SIG_EP_INPUT: [closures.WindowCovering.cluster_id], SIG_EP_OUTPUT: [], } @@ -130,10 +138,14 @@ async def test_cover( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": 65, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) assert cluster.read_attributes.call_count == 1 assert "current_position_lift_percentage" in cluster.read_attributes.call_args[0][0] + assert "current_position_tilt_percentage" in cluster.read_attributes.call_args[0][0] entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None @@ -146,6 +158,16 @@ async def test_cover( await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() + # test update + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 35 + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 58 + # test that the state has changed from unavailable to off await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -154,6 +176,14 @@ async def test_cover( await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == STATE_OPEN + # test that the state remains after tilting to 100% + await send_attributes_report(hass, cluster, {0: 0, 9: 100, 1: 1}) + assert hass.states.get(entity_id).state == STATE_OPEN + + # test to see the state remains after tilting to 0% + await send_attributes_report(hass, cluster, {0: 1, 9: 0, 1: 100}) + assert hass.states.get(entity_id).state == STATE_OPEN + # close from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -165,6 +195,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "down_close" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x1, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + # open from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -176,6 +220,20 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "up_open" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x0, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 0 + assert cluster.request.call_args[1]["expect_reply"] is True + # set position UI with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -191,6 +249,20 @@ async def test_cover( assert cluster.request.call_args[0][3] == 53 assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x5, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, ATTR_TILT_POSITION: 47}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 53 + assert cluster.request.call_args[1]["expect_reply"] is True + # stop from UI with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): await hass.services.async_call( @@ -202,11 +274,39 @@ async def test_cover( assert cluster.request.call_args[0][2].command.name == "stop" assert cluster.request.call_args[1]["expect_reply"] is True + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_STOP_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x02 + assert cluster.request.call_args[0][2].command.name == "stop" + assert cluster.request.call_args[1]["expect_reply"] is True + # test rejoin cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 0} await async_test_rejoin(hass, zigpy_cover_device, [cluster], (1,)) assert hass.states.get(entity_id).state == STATE_OPEN + # test toggle + with patch("zigpy.zcl.Cluster.request", return_value=[0x2, zcl_f.Status.SUCCESS]): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_TOGGLE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert cluster.request.call_args[0][0] is False + assert cluster.request.call_args[0][1] == 0x08 + assert cluster.request.call_args[0][2].command.name == "go_to_tilt_percentage" + assert cluster.request.call_args[0][3] == 100 + assert cluster.request.call_args[1]["expect_reply"] is True + async def test_cover_failures( hass: HomeAssistant, zha_device_joined_restored, zigpy_cover_device @@ -215,7 +315,10 @@ async def test_cover_failures( # load up cover domain cluster = zigpy_cover_device.endpoints.get(1).window_covering - cluster.PLUGGED_ATTR_READS = {"current_position_lift_percentage": 100} + cluster.PLUGGED_ATTR_READS = { + "current_position_lift_percentage": None, + "current_position_tilt_percentage": 42, + } zha_device = await zha_device_joined_restored(zigpy_cover_device) entity_id = find_entity_id(Platform.COVER, zha_device, hass) @@ -225,11 +328,17 @@ async def test_cover_failures( # test that the cover was created and that it is unavailable assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # test update returned None + prev_call_count = cluster.read_attributes.call_count + await async_update_entity(hass, entity_id) + assert cluster.read_attributes.call_count == prev_call_count + 2 + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + # allow traffic to flow through the gateway and device await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - # test that the state has changed from unavailable to off + # test that the state has changed from unavailable to closed await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED @@ -258,6 +367,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.down_close.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to close cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # open from UI with patch( "zigpy.zcl.Cluster.request", @@ -279,6 +408,26 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.up_open.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises(HomeAssistantError, match=r"Failed to open cover tilt"): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER_TILT, + {"entity_id": entity_id}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # set position UI with patch( "zigpy.zcl.Cluster.request", @@ -301,6 +450,28 @@ async def test_cover_failures( == closures.WindowCovering.ServerCommandDefs.go_to_lift_percentage.id ) + with patch( + "zigpy.zcl.Cluster.request", + return_value=Default_Response( + command_id=closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id, + status=zcl_f.Status.UNSUP_CLUSTER_COMMAND, + ), + ): + with pytest.raises( + HomeAssistantError, match=r"Failed to set cover tilt position" + ): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {"entity_id": entity_id, "tilt_position": 42}, + blocking=True, + ) + assert cluster.request.call_count == 1 + assert ( + cluster.request.call_args[0][1] + == closures.WindowCovering.ServerCommandDefs.go_to_tilt_percentage.id + ) + # stop from UI with patch( "zigpy.zcl.Cluster.request", @@ -499,11 +670,10 @@ async def test_shade( assert cluster_level.request.call_args[0][1] in (0x0003, 0x0007) -async def test_restore_state( +async def test_shade_restore_state( hass: HomeAssistant, zha_device_restored, zigpy_shade_device ) -> None: """Ensure states are restored on startup.""" - mock_restore_cache( hass, ( @@ -521,11 +691,38 @@ async def test_restore_state( entity_id = find_entity_id(Platform.COVER, zha_device, hass) assert entity_id is not None - # test that the cover was created and that it is unavailable + # test that the cover was created and that it is available assert hass.states.get(entity_id).state == STATE_OPEN assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 +async def test_cover_restore_state( + hass: HomeAssistant, zha_device_restored, zigpy_cover_device +) -> None: + """Ensure states are restored on startup.""" + mock_restore_cache( + hass, + ( + State( + "cover.fakemanufacturer_fakemodel_cover", + STATE_OPEN, + {ATTR_CURRENT_POSITION: 50, ATTR_CURRENT_TILT_POSITION: 42}, + ), + ), + ) + + hass.state = CoreState.starting + + zha_device = await zha_device_restored(zigpy_cover_device) + entity_id = find_entity_id(Platform.COVER, zha_device, hass) + assert entity_id is not None + + # test that the cover was created and that it is available + assert hass.states.get(entity_id).state == STATE_OPEN + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_POSITION] == 50 + assert hass.states.get(entity_id).attributes[ATTR_CURRENT_TILT_POSITION] == 42 + + async def test_keen_vent( hass: HomeAssistant, zha_device_joined_restored, zigpy_keen_vent ) -> None: From acc5edb0887aa7c4cf91a7d08c21350b55553d21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:56:53 +0200 Subject: [PATCH 769/968] Use real devices in binary_sensor device trigger tests (#102678) --- .../binary_sensor/test_device_trigger.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 4b8318e2d79..47abb29ae86 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -235,6 +235,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,10 +246,17 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -260,7 +268,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "bat_low", }, @@ -284,7 +292,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "not_bat_low", }, @@ -329,6 +337,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -340,10 +349,17 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -355,7 +371,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, @@ -398,6 +414,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -409,10 +426,17 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -424,7 +448,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, From 223abb6dcaa96c537f9d7936bbf279b1b97dd50a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:57:14 +0200 Subject: [PATCH 770/968] Use real devices in button device trigger tests (#102679) --- .../components/button/test_device_trigger.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/components/button/test_device_trigger.py b/tests/components/button/test_device_trigger.py index 32f10044206..e231fc3ae19 100644 --- a/tests/components/button/test_device_trigger.py +++ b/tests/components/button/test_device_trigger.py @@ -105,10 +105,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -121,7 +132,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "pressed", }, @@ -154,10 +165,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, "unknown") @@ -170,7 +192,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "pressed", }, From 13be486d61bf0fdf7621730114099739cd024cc7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:57:22 +0200 Subject: [PATCH 771/968] Use real devices in climate device trigger tests (#102680) --- .../components/climate/test_device_trigger.py | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index c600e4004e8..59efb66ff65 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -147,10 +147,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -171,7 +182,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, @@ -185,7 +196,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_temperature_changed", "above": 20, @@ -199,7 +210,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 10, @@ -257,10 +268,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -281,7 +303,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "hvac_mode_changed", "to": HVACMode.AUTO, From ec3596e85d639d35918e421866ee9610b02c97ac Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:57:33 +0200 Subject: [PATCH 772/968] Use real devices in cover device trigger tests (#102681) --- tests/components/cover/test_device_trigger.py | 85 +++++++++++++++---- 1 file changed, 67 insertions(+), 18 deletions(-) diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index fc82bbd1499..e464ff87c3f 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -378,10 +378,21 @@ async def test_get_trigger_capabilities_set_tilt_pos( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -394,7 +405,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", }, @@ -416,7 +427,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closed", }, @@ -438,7 +449,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opening", }, @@ -460,7 +471,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "closing", }, @@ -520,10 +531,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for state triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -536,7 +558,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "opened", }, @@ -569,10 +591,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLOSED) @@ -585,7 +618,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "opened", "for": {"seconds": 5}, @@ -627,6 +660,7 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -638,7 +672,14 @@ async def test_if_fires_on_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -650,7 +691,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -675,7 +716,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "below": 90, @@ -700,7 +741,7 @@ async def test_if_fires_on_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "position", "above": 45, @@ -773,6 +814,7 @@ async def test_if_fires_on_position( async def test_if_fires_on_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -784,7 +826,14 @@ async def test_if_fires_on_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -796,7 +845,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, @@ -821,7 +870,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "below": 90, @@ -846,7 +895,7 @@ async def test_if_fires_on_tilt_position( { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "tilt_position", "above": 45, From 4536720540f84f5944fdf8080213b551d9cc6b69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:57:42 +0200 Subject: [PATCH 773/968] Use real devices in device_automation device trigger tests (#102684) --- .../device_automation/test_toggle_entity.py | 84 +++++++++++-------- 1 file changed, 48 insertions(+), 36 deletions(-) diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index f02704cdc13..30c9e5b542e 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -4,12 +4,13 @@ from datetime import timedelta import pytest import homeassistant.components.automation as automation -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service @pytest.fixture(autouse=True, name="stub_blueprint_populate") @@ -24,22 +25,27 @@ def calls(hass): async def test_if_fires_on_state_change( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing. This is a sanity test for the toggle entity device automation helper, this is tested by each integration too. """ - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -50,8 +56,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_on", }, "action": { @@ -74,8 +80,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "turned_off", }, "action": { @@ -98,8 +104,8 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": "changed_states", }, "action": { @@ -122,40 +128,46 @@ async def test_if_fires_on_state_change( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 assert {calls[0].data["some"], calls[1].data["some"]} == { - f"turn_off device - {ent1.entity_id} - on - off - None", - f"turn_on_or_off device - {ent1.entity_id} - on - off - None", + f"turn_off device - {entry.entity_id} - on - off - None", + f"turn_on_or_off device - {entry.entity_id} - on - off - None", } - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entry.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 4 assert {calls[2].data["some"], calls[3].data["some"]} == { - f"turn_on device - {ent1.entity_id} - off - on - None", - f"turn_on_or_off device - {ent1.entity_id} - off - on - None", + f"turn_on device - {entry.entity_id} - off - on - None", + f"turn_on_or_off device - {entry.entity_id} - off - on - None", } @pytest.mark.parametrize("trigger", ["turned_off", "changed_states"]) async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, calls, enable_custom_integrations: None, trigger + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, + trigger, ) -> None: """Test for triggers firing with delay.""" - platform = getattr(hass.components, "test.switch") - - platform.init() - assert await async_setup_component( - hass, "switch", {"switch": {CONF_PLATFORM: "test"}} + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + "switch", "test", "5678", device_id=device_entry.id ) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + hass.states.async_set(entry.entity_id, STATE_ON) assert await async_setup_component( hass, @@ -166,8 +178,8 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": "switch", - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entry.entity_id, "type": trigger, "for": {"seconds": 5}, }, @@ -191,10 +203,10 @@ async def test_if_fires_on_state_change_with_for( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON + assert hass.states.get(entry.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entry.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 0 async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) @@ -202,5 +214,5 @@ async def test_if_fires_on_state_change_with_for( assert len(calls) == 1 await hass.async_block_till_done() assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format( - ent1.entity_id + entry.entity_id ) From 51f6dac97ff87bed0c7f5b781296655232758efe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:57:57 +0200 Subject: [PATCH 774/968] Use real devices in fan device trigger tests (#102686) --- tests/components/fan/test_device_trigger.py | 55 ++++++++++++++++----- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index f1de07a9e97..8ac5e79ba5b 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -176,10 +176,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -192,7 +203,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -214,7 +225,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -278,10 +289,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -294,7 +316,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -327,10 +349,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -343,7 +376,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, From cedade15eff4cc632cbb2366a9991de727e2d9b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:58:01 +0200 Subject: [PATCH 775/968] Use real devices in humidifier device trigger tests (#102687) --- .../humidifier/test_device_trigger.py | 50 +++++++++++++------ 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 1953494e0c0..34067d96ff2 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -162,10 +162,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -188,7 +199,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "below": 20, @@ -202,7 +213,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -216,7 +227,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "target_humidity_changed", "above": 30, @@ -231,7 +242,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "below": 30, @@ -245,7 +256,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -259,7 +270,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_humidity_changed", "above": 40, @@ -274,7 +285,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -298,7 +309,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -322,7 +333,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -423,10 +434,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -448,7 +470,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "target_humidity_changed", "below": 20, From 6edbee75f0a651f09d10b61b3398ef6af8a3d37c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:58:06 +0200 Subject: [PATCH 776/968] Use real devices in kodi device trigger tests (#102688) --- tests/components/kodi/test_device_trigger.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 3181f978112..4dbfe3abb57 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -88,7 +88,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_on", }, @@ -105,7 +105,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.id, "type": "turn_off", }, @@ -161,7 +161,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": entry.device_id, "entity_id": entry.entity_id, "type": "turn_on", }, From 6d1d3f42073bade793b49dc7aae1cdf7e9958246 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:58:22 +0200 Subject: [PATCH 777/968] Use real devices in device_tracker device trigger tests (#102685) --- .../device_tracker/test_device_trigger.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index 75209ec607b..3e19570ebcb 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -142,10 +142,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_zone_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -162,7 +173,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "enters", "zone": "zone.test", @@ -186,7 +197,7 @@ async def test_if_fires_on_zone_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "leaves", "zone": "zone.test", @@ -238,10 +249,21 @@ async def test_if_fires_on_zone_change( async def test_if_fires_on_zone_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for enter and leave triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -258,7 +280,7 @@ async def test_if_fires_on_zone_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "enters", "zone": "zone.test", From 691de148cf29d5636d89dee305428a4ce274e5cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 21:58:37 +0200 Subject: [PATCH 778/968] Use real devices in light device trigger tests (#102689) --- tests/components/light/test_device_trigger.py | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 085193e3b34..5ee6752640e 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -342,12 +360,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -360,7 +387,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, From f9fa1edabf6e3c1c3537ea1aedd423e548ea106e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:30:07 +0200 Subject: [PATCH 779/968] Use real devices in lock device trigger tests (#102690) --- tests/components/lock/test_device_trigger.py | 59 +++++++++++++++----- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 107e0924440..9c1594760c9 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -185,10 +185,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -201,7 +212,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", }, @@ -220,7 +231,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocked", }, @@ -259,10 +270,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -275,7 +297,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "locked", }, @@ -305,10 +327,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNLOCKED) @@ -321,7 +354,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locked", "for": {"seconds": 5}, @@ -346,7 +379,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlocking", "for": {"seconds": 5}, @@ -371,7 +404,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "jammed", "for": {"seconds": 5}, @@ -396,7 +429,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "locking", "for": {"seconds": 5}, From eac1d47ec60a7abf81fc8763b6c97a54f58a3a1e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:31:16 +0200 Subject: [PATCH 780/968] Use real devices in media_player device trigger tests (#102691) --- .../media_player/test_device_trigger.py | 51 +++++++++++++++---- 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index 42608eacb09..afc46c87cff 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -205,10 +205,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -236,7 +247,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": trigger, }, @@ -306,10 +317,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -328,7 +350,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_on", }, @@ -354,10 +376,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OFF) @@ -370,7 +403,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", "for": {"seconds": 5}, From 952f40a18183e008b598f52ef93e95a10106459d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:33:08 +0200 Subject: [PATCH 781/968] Use real devices in alarm_control_panel device trigger tests (#102676) --- .../test_device_trigger.py | 61 ++++++++++++++----- 1 file changed, 47 insertions(+), 14 deletions(-) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index 57b9f8125c2..70d700bb290 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -246,10 +246,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ): """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_PENDING) @@ -262,7 +273,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", }, @@ -284,7 +295,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "disarmed", }, @@ -306,7 +317,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_home", }, @@ -328,7 +339,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_away", }, @@ -350,7 +361,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_night", }, @@ -372,7 +383,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "armed_vacation", }, @@ -450,10 +461,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -466,7 +488,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "triggered", "for": {"seconds": 5}, @@ -507,10 +529,21 @@ async def test_if_fires_on_state_change_with_for( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ALARM_DISARMED) @@ -523,7 +556,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "triggered", }, From 82cc62416ec1011f731407250839278bafeb9ac8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:37:44 +0200 Subject: [PATCH 782/968] Use real devices in sensor device trigger tests (#102695) --- .../components/sensor/test_device_trigger.py | 78 ++++++++++++++++--- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 7045d71fb78..bbc59cca322 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -418,13 +418,22 @@ async def test_get_trigger_capabilities_none( async def test_if_fires_not_on_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -435,7 +444,7 @@ async def test_if_fires_not_on_above_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", }, @@ -449,12 +458,21 @@ async def test_if_fires_not_on_above_below( async def test_if_fires_on_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -467,7 +485,7 @@ async def test_if_fires_on_state_above( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -508,12 +526,21 @@ async def test_if_fires_on_state_above( async def test_if_fires_on_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -526,7 +553,7 @@ async def test_if_fires_on_state_below( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "below": 10, @@ -567,12 +594,21 @@ async def test_if_fires_on_state_below( async def test_if_fires_on_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -585,7 +621,7 @@ async def test_if_fires_on_state_between( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, @@ -638,12 +674,21 @@ async def test_if_fires_on_state_between( async def test_if_fires_on_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -656,7 +701,7 @@ async def test_if_fires_on_state_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "battery_level", "above": 10, @@ -697,12 +742,21 @@ async def test_if_fires_on_state_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -715,7 +769,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "battery_level", "above": 10, From 8737d84d305062185cb7f20e1de16f59696dbc32 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:41:27 +0200 Subject: [PATCH 783/968] Use real devices in switch device trigger tests (#102696) --- .../components/switch/test_device_trigger.py | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 32f8f65b114..03f7e8fbb8e 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -288,12 +297,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -306,7 +324,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -343,12 +361,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -361,7 +388,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, From d8baa38751c4b152b0791316038069c02b28fd8c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:42:44 +0200 Subject: [PATCH 784/968] Use real devices in update device trigger tests (#102697) --- .../components/update/test_device_trigger.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py index b2d06a642a8..16749167c41 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -176,6 +176,7 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -187,7 +188,14 @@ async def test_if_fires_on_state_change( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -198,7 +206,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -222,7 +230,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": "update.update_available", "type": "turned_off", }, @@ -270,6 +278,7 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -281,7 +290,14 @@ async def test_if_fires_on_state_change_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -292,7 +308,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -332,6 +348,7 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls: list[ServiceCall], enable_custom_integrations: None, @@ -343,7 +360,14 @@ async def test_if_fires_on_state_change_with_for( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get("update.update_available") + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -354,7 +378,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, From 2807c9eaca01361ef6f9aa00c865a5ba6a09fdae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:43:34 +0200 Subject: [PATCH 785/968] Use real devices in vacuum device trigger tests (#102698) --- .../components/vacuum/test_device_trigger.py | 53 +++++++++++++++---- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index 2f27d299d7e..605dd6e5b9f 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -178,10 +178,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -194,7 +205,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", }, @@ -213,7 +224,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "docked", }, @@ -252,10 +263,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -268,7 +290,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "cleaning", }, @@ -298,10 +320,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -314,7 +347,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "cleaning", "for": {"seconds": 5}, From ee1007abdb90bec68356c479be2d483696f65c6a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 22:44:50 +0200 Subject: [PATCH 786/968] Use real devices in wemo device trigger tests (#102699) --- tests/components/wemo/test_device_trigger.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/components/wemo/test_device_trigger.py b/tests/components/wemo/test_device_trigger.py index 4ae8dcaddb1..9140f5f1e35 100644 --- a/tests/components/wemo/test_device_trigger.py +++ b/tests/components/wemo/test_device_trigger.py @@ -19,7 +19,6 @@ from homeassistant.setup import async_setup_component from tests.common import async_get_device_automations, async_mock_service -MOCK_DEVICE_ID = "some-device-id" DATA_MESSAGE = {"message": "service-called"} @@ -96,12 +95,12 @@ async def test_get_triggers(hass: HomeAssistant, wemo_entity) -> None: assert triggers == unordered(expected_triggers) -async def test_fires_on_long_press(hass: HomeAssistant) -> None: +async def test_fires_on_long_press(hass: HomeAssistant, wemo_entity) -> None: """Test wemo long press trigger firing.""" - assert await setup_automation(hass, MOCK_DEVICE_ID, EVENT_TYPE_LONG_PRESS) + assert await setup_automation(hass, wemo_entity.device_id, EVENT_TYPE_LONG_PRESS) calls = async_mock_service(hass, "test", "automation") - message = {CONF_DEVICE_ID: MOCK_DEVICE_ID, CONF_TYPE: EVENT_TYPE_LONG_PRESS} + message = {CONF_DEVICE_ID: wemo_entity.device_id, CONF_TYPE: EVENT_TYPE_LONG_PRESS} hass.bus.async_fire(WEMO_SUBSCRIPTION_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 From 0b8f48205a60fa7147231f6ee9d3047a1f9a40d5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Oct 2023 13:47:26 -0700 Subject: [PATCH 787/968] Add Todoist To-do list support (#102633) * Add todoist todo platform * Fix comment in todoist todo platform * Revert CalData cleanup and logging * Fix bug in fetching tasks per project * Add test coverage for creating active tasks * Fix update behavior on startup --- homeassistant/components/todoist/__init__.py | 2 +- homeassistant/components/todoist/todo.py | 111 ++++++++ tests/components/todoist/conftest.py | 47 +++- tests/components/todoist/test_calendar.py | 14 +- tests/components/todoist/test_init.py | 12 +- tests/components/todoist/test_todo.py | 256 +++++++++++++++++++ 6 files changed, 414 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/todoist/todo.py create mode 100644 tests/components/todoist/test_todo.py diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 12b75a40bae..60c40b1c03c 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=1) -PLATFORMS: list[Platform] = [Platform.CALENDAR] +PLATFORMS: list[Platform] = [Platform.CALENDAR, Platform.TODO] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/todoist/todo.py b/homeassistant/components/todoist/todo.py new file mode 100644 index 00000000000..c0d3ec6e2ce --- /dev/null +++ b/homeassistant/components/todoist/todo.py @@ -0,0 +1,111 @@ +"""A todo platform for Todoist.""" + +import asyncio +from typing import cast + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist todo platform config entry.""" + coordinator: TodoistCoordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + async_add_entities( + TodoistTodoListEntity(coordinator, entry.entry_id, project.id, project.name) + for project in projects + ) + + +class TodoistTodoListEntity(CoordinatorEntity[TodoistCoordinator], TodoListEntity): + """A Todoist TodoListEntity.""" + + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + ) + + def __init__( + self, + coordinator: TodoistCoordinator, + config_entry_id: str, + project_id: str, + project_name: str, + ) -> None: + """Initialize TodoistTodoListEntity.""" + super().__init__(coordinator=coordinator) + self._project_id = project_id + self._attr_unique_id = f"{config_entry_id}-{project_id}" + self._attr_name = project_name + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if self.coordinator.data is None: + self._attr_todo_items = None + else: + items = [] + for task in self.coordinator.data: + if task.project_id != self._project_id: + continue + if task.is_completed: + status = TodoItemStatus.COMPLETED + else: + status = TodoItemStatus.NEEDS_ACTION + items.append( + TodoItem( + summary=task.content, + uid=task.id, + status=status, + ) + ) + self._attr_todo_items = items + super()._handle_coordinator_update() + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Create a To-do item.""" + if item.status != TodoItemStatus.NEEDS_ACTION: + raise ValueError("Only active tasks may be created.") + await self.coordinator.api.add_task( + content=item.summary or "", + project_id=self._project_id, + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + if item.summary: + await self.coordinator.api.update_task(task_id=uid, content=item.summary) + if item.status is not None: + if item.status == TodoItemStatus.COMPLETED: + await self.coordinator.api.close_task(task_id=uid) + else: + await self.coordinator.api.reopen_task(task_id=uid) + await self.coordinator.async_refresh() + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Delete a To-do item.""" + await asyncio.gather( + *[self.coordinator.api.delete_task(task_id=uid) for uid in uids] + ) + await self.coordinator.async_refresh() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass update state from existing coordinator data.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py index 6543e5b678f..28f22e1061a 100644 --- a/tests/components/todoist/conftest.py +++ b/tests/components/todoist/conftest.py @@ -9,15 +9,17 @@ from requests.models import Response from todoist_api_python.models import Collaborator, Due, Label, Project, Task from homeassistant.components.todoist import DOMAIN -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +PROJECT_ID = "project-id-1" SUMMARY = "A task" TOKEN = "some-token" +TODAY = dt_util.now().strftime("%Y-%m-%d") @pytest.fixture @@ -37,38 +39,49 @@ def mock_due() -> Due: ) -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: +def make_api_task( + id: str | None = None, + content: str | None = None, + is_completed: bool = False, + due: Due | None = None, + project_id: str | None = None, +) -> Task: """Mock a todoist Task instance.""" return Task( assignee_id="1", assigner_id="1", comment_count=0, - is_completed=False, - content=SUMMARY, + is_completed=is_completed, + content=content or SUMMARY, created_at="2021-10-01T00:00:00", creator_id="1", description="A task", - due=due, - id="1", + due=due or Due(is_recurring=False, date=TODAY, string="today"), + id=id or "1", labels=["Label1"], order=1, parent_id=None, priority=1, - project_id="12345", + project_id=project_id or PROJECT_ID, section_id=None, url="https://todoist.com", sync_id=None, ) +@pytest.fixture(name="tasks") +def mock_tasks(due: Due) -> list[Task]: + """Mock a todoist Task instance.""" + return [make_api_task(due=due)] + + @pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: +def mock_api(tasks: list[Task]) -> AsyncMock: """Mock the api state.""" api = AsyncMock() api.get_projects.return_value = [ Project( - id="12345", + id=PROJECT_ID, color="blue", comment_count=0, is_favorite=False, @@ -88,7 +101,7 @@ def mock_api(task) -> AsyncMock: api.get_collaborators.return_value = [ Collaborator(email="user@gmail.com", id="1", name="user") ] - api.get_tasks.return_value = [task] + api.get_tasks.return_value = tasks return api @@ -121,15 +134,25 @@ def mock_todoist_domain() -> str: return DOMAIN +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + @pytest.fixture(name="setup_integration") async def mock_setup_integration( hass: HomeAssistant, + platforms: list[Platform], api: AsyncMock, todoist_config_entry: MockConfigEntry | None, ) -> None: """Mock setup of the todoist integration.""" if todoist_config_entry is not None: todoist_config_entry.add_to_hass(hass) - with patch("homeassistant.components.todoist.TodoistAPIAsync", return_value=api): + with patch( + "homeassistant.components.todoist.TodoistAPIAsync", return_value=api + ), patch("homeassistant.components.todoist.PLATFORMS", platforms): assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 45300e2e66c..761eeb07c61 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -18,13 +18,13 @@ from homeassistant.components.todoist.const import ( PROJECT_NAME, SERVICE_NEW_TASK, ) -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util -from .conftest import SUMMARY +from .conftest import PROJECT_ID, SUMMARY from tests.typing import ClientSessionGenerator @@ -34,6 +34,12 @@ TZ_NAME = "America/Regina" TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Override platforms.""" + return [Platform.CALENDAR] + + @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" @@ -97,7 +103,7 @@ async def test_calendar_entity_unique_id( ) -> None: """Test unique id is set to project id.""" entity = entity_registry.async_get("calendar.name") - assert entity.unique_id == "12345" + assert entity.unique_id == PROJECT_ID @pytest.mark.parametrize( @@ -256,7 +262,7 @@ async def test_create_task_service_call(hass: HomeAssistant, api: AsyncMock) -> await hass.async_block_till_done() api.add_task.assert_called_with( - "task", project_id="12345", labels=["Label1"], assignee_id="1" + "task", project_id=PROJECT_ID, labels=["Label1"], assignee_id="1" ) diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py index cc64464df1d..0e80be5410f 100644 --- a/tests/components/todoist/test_init.py +++ b/tests/components/todoist/test_init.py @@ -1,7 +1,6 @@ """Unit tests for the Todoist integration.""" -from collections.abc import Generator from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest @@ -12,15 +11,6 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -@pytest.fixture(autouse=True) -def mock_platforms() -> Generator[AsyncMock, None, None]: - """Override async_setup_entry.""" - with patch( - "homeassistant.components.todoist.PLATFORMS", return_value=[] - ) as mock_setup_entry: - yield mock_setup_entry - - async def test_load_unload( hass: HomeAssistant, setup_integration: None, diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py new file mode 100644 index 00000000000..bbfaf6c493b --- /dev/null +++ b/tests/components/todoist/test_todo.py @@ -0,0 +1,256 @@ +"""Unit tests for the Todoist todo platform.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_component import async_update_entity + +from .conftest import PROJECT_ID, make_api_task + + +@pytest.fixture(autouse=True) +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.mark.parametrize( + ("tasks", "expected_state"), + [ + ([], "0"), + ([make_api_task(id="12345", content="Soda", is_completed=False)], "1"), + ([make_api_task(id="12345", content="Soda", is_completed=True)], "0"), + ( + [ + make_api_task(id="12345", content="Milk", is_completed=False), + make_api_task(id="54321", content="Soda", is_completed=False), + ], + "2", + ), + ( + [ + make_api_task( + id="12345", + content="Soda", + is_completed=False, + project_id="other-project-id", + ) + ], + "0", + ), + ], +) +async def test_todo_item_state( + hass: HomeAssistant, + setup_integration: None, + expected_state: str, +) -> None: + """Test for a To-do List entity state.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == expected_state + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + # Fake API response when state is refreshed after create + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + args = api.add_task.call_args + assert args + assert args.kwargs.get("content") == "Soda" + assert args.kwargs.get("project_id") == PROJECT_ID + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize(("tasks"), [[]]) +async def test_create_completed_item_unsupported( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for creating a To-do Item that is already completed.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + api.add_task = AsyncMock() + + with pytest.raises(ValueError, match="Only active tasks"): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_status( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the status.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.close_task = AsyncMock() + api.reopen_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "completed"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.close_task.called + args = api.close_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert not api.reopen_task.called + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "0" + + # Fake API response when state is refreshed after reopen + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=False) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "status": "needs_action"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.reopen_task.called + args = api.reopen_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + + # Verify state is refreshed + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] +) +async def test_update_todo_item_summary( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for updating a To-do Item that changes the summary.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "1" + + api.update_task = AsyncMock() + + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [ + make_api_task(id="task-id-1", content="Soda", is_completed=True) + ] + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "task-id-1", "summary": "Milk"}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.update_task.called + args = api.update_task.call_args + assert args + assert args.kwargs.get("task_id") == "task-id-1" + assert args.kwargs.get("content") == "Milk" + + +@pytest.mark.parametrize( + ("tasks"), + [ + [ + make_api_task(id="task-id-1", content="Soda", is_completed=False), + make_api_task(id="task-id-2", content="Milk", is_completed=False), + ] + ], +) +async def test_delete_todo_item( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, +) -> None: + """Test for deleting a To-do Item.""" + + state = hass.states.get("todo.name") + assert state + assert state.state == "2" + + api.delete_task = AsyncMock() + # Fake API response when state is refreshed after close + api.get_tasks.return_value = [] + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": ["task-id-1", "task-id-2"]}, + target={"entity_id": "todo.name"}, + blocking=True, + ) + assert api.delete_task.call_count == 2 + args = api.delete_task.call_args_list + assert args[0].kwargs.get("task_id") == "task-id-1" + assert args[1].kwargs.get("task_id") == "task-id-2" + + await async_update_entity(hass, "todo.name") + state = hass.states.get("todo.name") + assert state + assert state.state == "0" From f5a6c88051bf6daaac1d0ce02e1ce3ca032806e1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 24 Oct 2023 23:00:14 +0200 Subject: [PATCH 788/968] Don't load themes in safe mode (#102683) --- homeassistant/components/frontend/__init__.py | 11 +++-------- tests/components/frontend/test_init.py | 15 +++++++++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0225d723d20..8201cbc5b7a 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -666,18 +666,13 @@ def websocket_get_themes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get themes command.""" - if hass.config.recovery_mode: + if hass.config.recovery_mode or hass.config.safe_mode: connection.send_message( websocket_api.result_message( msg["id"], { - "themes": { - "recovery_mode": { - "primary-color": "#db4437", - "accent-color": "#ffca28", - } - }, - "default_theme": "recovery_mode", + "themes": {}, + "default_theme": "default", }, ) ) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 4f75bc2e790..e3f0d7f35d5 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -180,10 +180,17 @@ async def test_themes_api(hass: HomeAssistant, themes_ws_client) -> None: await themes_ws_client.send_json({"id": 6, "type": "frontend/get_themes"}) msg = await themes_ws_client.receive_json() - assert msg["result"]["default_theme"] == "recovery_mode" - assert msg["result"]["themes"] == { - "recovery_mode": {"primary-color": "#db4437", "accent-color": "#ffca28"} - } + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} + + # safe mode + hass.config.recovery_mode = False + hass.config.safe_mode = True + await themes_ws_client.send_json({"id": 7, "type": "frontend/get_themes"}) + msg = await themes_ws_client.receive_json() + + assert msg["result"]["default_theme"] == "default" + assert msg["result"]["themes"] == {} async def test_themes_persist( From a5461a9a90f52fac1eadbe0104ff4b86a6c64725 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Tue, 24 Oct 2023 23:11:16 +0200 Subject: [PATCH 789/968] Bump plugwise to v0.33.2 (#102671) --- homeassistant/components/plugwise/climate.py | 4 +--- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/plugwise/fixtures/adam_jip/all_data.json | 4 ---- .../fixtures/adam_multiple_devices_per_zone/all_data.json | 5 ----- .../plugwise/fixtures/anna_heatpump_heating/all_data.json | 1 - .../plugwise/fixtures/m_adam_cooling/all_data.json | 2 -- .../plugwise/fixtures/m_adam_heating/all_data.json | 2 -- .../plugwise/fixtures/m_anna_heatpump_cooling/all_data.json | 1 - .../plugwise/fixtures/m_anna_heatpump_idle/all_data.json | 1 - tests/components/plugwise/snapshots/test_diagnostics.ambr | 5 ----- tests/components/plugwise/test_climate.py | 6 +++--- 13 files changed, 7 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/plugwise/climate.py b/homeassistant/components/plugwise/climate.py index e25c2e72e05..a33cef0e3a7 100644 --- a/homeassistant/components/plugwise/climate.py +++ b/homeassistant/components/plugwise/climate.py @@ -169,9 +169,7 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity): raise HomeAssistantError("Unsupported hvac_mode") await self.coordinator.api.set_schedule_state( - self.device["location"], - self.device["last_used"], - "on" if hvac_mode == HVACMode.AUTO else "off", + self.device["location"], "on" if hvac_mode == HVACMode.AUTO else "off" ) @plugwise_command diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index b4cc418cc7e..1155aaffdf8 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -7,6 +7,6 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["crcmod", "plugwise"], - "requirements": ["plugwise==0.33.1"], + "requirements": ["plugwise==0.33.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 903198fcf75..61950f924f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1455,7 +1455,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.1 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ca6129ce29..843a1d565a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1115,7 +1115,7 @@ plexauth==0.0.6 plexwebsocket==0.0.14 # homeassistant.components.plugwise -plugwise==0.33.1 +plugwise==0.33.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/tests/components/plugwise/fixtures/adam_jip/all_data.json b/tests/components/plugwise/fixtures/adam_jip/all_data.json index ba00e3928d7..bc1bc9c8c0c 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -7,7 +7,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "06aecb3d00354375924f50c47af36bd2", "mode": "heat", "model": "Lisa", @@ -103,7 +102,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d27aede973b54be484f6842d1b2802ad", "mode": "heat", "model": "Lisa", @@ -160,7 +158,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": null, "location": "d58fec52899f4f1c92e4f8fad6d8c48c", "mode": "heat", "model": "Lisa", @@ -271,7 +268,6 @@ "dev_class": "zone_thermometer", "firmware": "2020-09-01T02:00:00+02:00", "hardware": "1", - "last_used": null, "location": "13228dab8ce04617af318a2888b3c548", "mode": "heat", "model": "Jip", diff --git a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json index 0cc28731ff4..6e6da1aa272 100644 --- a/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json +++ b/tests/components/plugwise/fixtures/adam_multiple_devices_per_zone/all_data.json @@ -117,7 +117,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "CV Jessie", "location": "82fa13f017d240daa0d0ea1775420f24", "mode": "auto", "model": "Lisa", @@ -257,7 +256,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-08-02T02:00:00+02:00", "hardware": "255", - "last_used": "GF7 Woonkamer", "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", "model": "Lisa", @@ -341,7 +339,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "12493538af164a409c6a1c79e38afe1c", "mode": "heat", "model": "Lisa", @@ -381,7 +378,6 @@ "dev_class": "thermostatic_radiator_valve", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "last_used": "Badkamer Schema", "location": "446ac08dd04d4eff8ac57489757b7314", "mode": "heat", "model": "Tom/Floor", @@ -423,7 +419,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer Schema", "location": "08963fec7c53423ca5680aa4cb502c63", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json index cdddfdb3439..e7e13e17357 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -62,7 +62,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json index af8c012cae3..126852e945d 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -54,7 +54,6 @@ "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat_cool", "model": "ThermoTouch", @@ -108,7 +107,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json index efefa95d45c..e8a72c9b3fb 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -59,7 +59,6 @@ "available": true, "available_schedules": ["Weekschema", "Badkamer", "Test"], "dev_class": "thermostat", - "last_used": "Weekschema", "location": "f2bf9048bef64cc5b6d5110154e33c81", "mode": "heat", "model": "ThermoTouch", @@ -105,7 +104,6 @@ "dev_class": "zone_thermostat", "firmware": "2016-10-10T02:00:00+02:00", "hardware": "255", - "last_used": "Badkamer", "location": "f871b8c4d63549319221e294e4f88074", "mode": "auto", "model": "Lisa", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json index f98f253e938..40364e620c3 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_cooling/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json index 56d26f67acb..3a84a59deea 100644 --- a/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json +++ b/tests/components/plugwise/fixtures/m_anna_heatpump_idle/all_data.json @@ -63,7 +63,6 @@ "dev_class": "thermostat", "firmware": "2018-02-08T11:15:53+01:00", "hardware": "6539-1301-5002", - "last_used": "standaard", "location": "c784ee9fdab44e1395b8dee7d7a497d5", "mode": "auto", "model": "ThermoTouch", diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr index da6e8964421..597b9710ec5 100644 --- a/tests/components/plugwise/snapshots/test_diagnostics.ambr +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -119,7 +119,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'CV Jessie', 'location': '82fa13f017d240daa0d0ea1775420f24', 'mode': 'auto', 'model': 'Lisa', @@ -265,7 +264,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-08-02T02:00:00+02:00', 'hardware': '255', - 'last_used': 'GF7 Woonkamer', 'location': 'c50f167537524366a5af7aa3942feb1e', 'mode': 'auto', 'model': 'Lisa', @@ -355,7 +353,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '12493538af164a409c6a1c79e38afe1c', 'mode': 'heat', 'model': 'Lisa', @@ -401,7 +398,6 @@ 'dev_class': 'thermostatic_radiator_valve', 'firmware': '2019-03-27T01:00:00+01:00', 'hardware': '1', - 'last_used': 'Badkamer Schema', 'location': '446ac08dd04d4eff8ac57489757b7314', 'mode': 'heat', 'model': 'Tom/Floor', @@ -449,7 +445,6 @@ 'dev_class': 'zone_thermostat', 'firmware': '2016-10-27T02:00:00+02:00', 'hardware': '255', - 'last_used': 'Badkamer Schema', 'location': '08963fec7c53423ca5680aa4cb502c63', 'mode': 'auto', 'model': 'Lisa', diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py index 11425cf79da..d8ce2785f2a 100644 --- a/tests/components/plugwise/test_climate.py +++ b/tests/components/plugwise/test_climate.py @@ -158,7 +158,7 @@ async def test_adam_climate_entity_climate_changes( ) assert mock_smile_adam.set_schedule_state.call_count == 2 mock_smile_adam.set_schedule_state.assert_called_with( - "c50f167537524366a5af7aa3942feb1e", "GF7 Woonkamer", "off" + "c50f167537524366a5af7aa3942feb1e", "off" ) with pytest.raises(HomeAssistantError): @@ -272,7 +272,7 @@ async def test_anna_climate_entity_climate_changes( ) assert mock_smile_anna.set_schedule_state.call_count == 1 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "on" + "c784ee9fdab44e1395b8dee7d7a497d5", "on" ) await hass.services.async_call( @@ -283,7 +283,7 @@ async def test_anna_climate_entity_climate_changes( ) assert mock_smile_anna.set_schedule_state.call_count == 2 mock_smile_anna.set_schedule_state.assert_called_with( - "c784ee9fdab44e1395b8dee7d7a497d5", "standaard", "off" + "c784ee9fdab44e1395b8dee7d7a497d5", "off" ) data = mock_smile_anna.async_update.return_value data.devices["3cb70739631c4d17a86b8b12e8a5161b"]["available_schedules"] = ["None"] From 5ee14f7f7d5fa935c35fed9c5a251511a1ffb4dd Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Oct 2023 21:14:05 +0000 Subject: [PATCH 790/968] Bump `accuweather` to version 2.0.0 (#102670) --- homeassistant/components/accuweather/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 3a834261af5..5a5a1de2a01 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==1.0.0"] + "requirements": ["accuweather==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 61950f924f4..c31b83d4e57 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ TwitterAPI==2.7.12 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax adax==0.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 843a1d565a7..47a89037a77 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ Tami4EdgeAPI==2.1 WSDiscovery==2.0.0 # homeassistant.components.accuweather -accuweather==1.0.0 +accuweather==2.0.0 # homeassistant.components.adax adax==0.3.0 From fd8fdba7e8ace9bf3b7b90283317f1bafd94b330 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 24 Oct 2023 23:18:10 +0200 Subject: [PATCH 791/968] Replace ZHA quirk class matching with quirk ID matching (#102482) * Use fixed quirk IDs for matching instead of quirk class * Change tests for quirk id (WIP) * Do not default `quirk_id` to `quirk_class` * Implement test for checking if quirk ID exists * Change `quirk_id` for test slightly (underscore instead of dot) --- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 3 + .../components/zha/core/discovery.py | 8 +- .../components/zha/core/registries.py | 65 ++++++++------ tests/components/zha/test_registries.py | 88 +++++++++---------- 5 files changed, 85 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c286d0112e9..9874fddc598 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -48,6 +48,7 @@ ATTR_POWER_SOURCE = "power_source" ATTR_PROFILE_ID = "profile_id" ATTR_QUIRK_APPLIED = "quirk_applied" ATTR_QUIRK_CLASS = "quirk_class" +ATTR_QUIRK_ID = "quirk_id" ATTR_ROUTES = "routes" ATTR_RSSI = "rssi" ATTR_SIGNATURE = "signature" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 8f5b087f068..44acbb172fc 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -59,6 +59,7 @@ from .const import ( ATTR_POWER_SOURCE, ATTR_QUIRK_APPLIED, ATTR_QUIRK_CLASS, + ATTR_QUIRK_ID, ATTR_ROUTES, ATTR_RSSI, ATTR_SIGNATURE, @@ -135,6 +136,7 @@ class ZHADevice(LogMixin): f"{self._zigpy_device.__class__.__module__}." f"{self._zigpy_device.__class__.__name__}" ) + self.quirk_id = getattr(self._zigpy_device, ATTR_QUIRK_ID, None) if self.is_mains_powered: self.consider_unavailable_time = async_get_zha_config_value( @@ -537,6 +539,7 @@ class ZHADevice(LogMixin): ATTR_NAME: self.name or ieee, ATTR_QUIRK_APPLIED: self.quirk_applied, ATTR_QUIRK_CLASS: self.quirk_class, + ATTR_QUIRK_ID: self.quirk_id, ATTR_MANUFACTURER_CODE: self.manufacturer_code, ATTR_POWER_SOURCE: self.power_source, ATTR_LQI: self.lqi, diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index a56e7044d3a..90ed68f9b00 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -122,7 +122,7 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, cluster_handlers, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if platform_entity_class is None: return @@ -181,7 +181,7 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, cluster_handler_list, - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) if entity_class is None: return @@ -226,14 +226,14 @@ class ProbeEndpoint: endpoint.device.manufacturer, endpoint.device.model, list(endpoint.all_cluster_handlers.values()), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) else: matches, claimed = zha_regs.ZHA_ENTITIES.get_multi_entity( endpoint.device.manufacturer, endpoint.device.model, endpoint.unclaimed_cluster_handlers(), - endpoint.device.quirk_class, + endpoint.device.quirk_id, ) endpoint.claim_cluster_handlers(claimed) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 74f724bdc49..4bdedebfff9 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -147,7 +147,7 @@ class MatchRule: aux_cluster_handlers: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) - quirk_classes: frozenset[str] | Callable = attr.ib( + quirk_ids: frozenset[str] | Callable = attr.ib( factory=_get_empty_frozenset, converter=set_or_callable ) @@ -165,10 +165,8 @@ class MatchRule: multiple cluster handlers a better priority over rules matching a single cluster handler. """ weight = 0 - if self.quirk_classes: - weight += 501 - ( - 1 if callable(self.quirk_classes) else len(self.quirk_classes) - ) + if self.quirk_ids: + weight += 501 - (1 if callable(self.quirk_ids) else len(self.quirk_ids)) if self.models: weight += 401 - (1 if callable(self.models) else len(self.models)) @@ -204,19 +202,31 @@ class MatchRule: return claimed def strict_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return all(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return all(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def loose_matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> bool: """Return True if this device matches the criteria.""" - return any(self._matched(manufacturer, model, cluster_handlers, quirk_class)) + return any(self._matched(manufacturer, model, cluster_handlers, quirk_id)) def _matched( - self, manufacturer: str, model: str, cluster_handlers: list, quirk_class: str + self, + manufacturer: str, + model: str, + cluster_handlers: list, + quirk_id: str | None, ) -> list: """Return a list of field matches.""" if not any(attr.asdict(self).values()): @@ -243,14 +253,11 @@ class MatchRule: else: matches.append(model in self.models) - if self.quirk_classes: - if callable(self.quirk_classes): - matches.append(self.quirk_classes(quirk_class)) + if self.quirk_ids and quirk_id: + if callable(self.quirk_ids): + matches.append(self.quirk_ids(quirk_id)) else: - matches.append( - quirk_class.split(".")[-2:] - in [x.split(".")[-2:] for x in self.quirk_classes] - ) + matches.append(quirk_id in self.quirk_ids) return matches @@ -292,13 +299,13 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, default: type[ZhaEntity] | None = None, ) -> tuple[type[ZhaEntity] | None, list[ClusterHandler]]: """Match a ZHA ClusterHandler to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): - if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): + if match.strict_matched(manufacturer, model, cluster_handlers, quirk_id): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -309,7 +316,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -323,7 +330,7 @@ class ZHAEntityRegistry: sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -342,7 +349,7 @@ class ZHAEntityRegistry: manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], - quirk_class: str, + quirk_id: str | None, ) -> tuple[ dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] ]: @@ -359,7 +366,7 @@ class ZHAEntityRegistry: sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( - manufacturer, model, cluster_handlers, quirk_class + manufacturer, model, cluster_handlers, quirk_id ): claimed = match.claim_cluster_handlers(cluster_handlers) for ent_class in stop_match_groups[stop_match_grp][match]: @@ -385,7 +392,7 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str | None = None, models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" @@ -395,7 +402,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: @@ -417,7 +424,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -427,7 +434,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: @@ -452,7 +459,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str | None = None, aux_cluster_handlers: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, - quirk_classes: set[str] | str | None = None, + quirk_ids: set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -462,7 +469,7 @@ class ZHAEntityRegistry: manufacturers, models, aux_cluster_handlers, - quirk_classes, + quirk_ids, ) def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 2eb61402a95..68ff116adea 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -1,15 +1,14 @@ """Test ZHA registries.""" from __future__ import annotations -import importlib -import inspect import typing from unittest import mock import pytest -import zhaquirks +import zigpy.quirks as zigpy_quirks from homeassistant.components.zha.binary_sensor import IASZone +from homeassistant.components.zha.core.const import ATTR_QUIRK_ID import homeassistant.components.zha.core.registries as registries from homeassistant.helpers import entity_registry as er @@ -19,7 +18,7 @@ if typing.TYPE_CHECKING: MANUFACTURER = "mock manufacturer" MODEL = "mock model" QUIRK_CLASS = "mock.test.quirk.class" -QUIRK_CLASS_SHORT = "quirk.class" +QUIRK_ID = "quirk_id" @pytest.fixture @@ -29,6 +28,7 @@ def zha_device(): dev.manufacturer = MANUFACTURER dev.model = MODEL dev.quirk_class = QUIRK_CLASS + dev.quirk_id = QUIRK_ID return dev @@ -107,17 +107,17 @@ def cluster_handlers(cluster_handler): ), False, ), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), ( registries.MatchRule( - quirk_classes=QUIRK_CLASS, aux_cluster_handlers="aux_cluster_handler" + quirk_ids=QUIRK_ID, aux_cluster_handlers="aux_cluster_handler" ), True, ), ( registries.MatchRule( - quirk_classes="no match", aux_cluster_handlers="aux_cluster_handler" + quirk_ids="no match", aux_cluster_handlers="aux_cluster_handler" ), False, ), @@ -128,7 +128,7 @@ def cluster_handlers(cluster_handler): cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -187,33 +187,31 @@ def cluster_handlers(cluster_handler): ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", QUIRK_CLASS}, + quirk_ids={"random quirk", QUIRK_ID}, ), True, ), ( registries.MatchRule( cluster_handler_names="on_off", - quirk_classes={"random quirk", "another quirk"}, + quirk_ids={"random quirk", "another quirk"}, ), False, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x == QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x == QUIRK_ID ), True, ), ( registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=lambda x: x != QUIRK_CLASS + cluster_handler_names="on_off", quirk_ids=lambda x: x != QUIRK_ID ), False, ), ( - registries.MatchRule( - cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS_SHORT - ), + registries.MatchRule(cluster_handler_names="on_off", quirk_ids=QUIRK_ID), True, ), ], @@ -221,8 +219,7 @@ def cluster_handlers(cluster_handler): def test_registry_matching(rule, matched, cluster_handlers) -> None: """Test strict rule matching.""" assert ( - rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.strict_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -314,8 +311,8 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: (registries.MatchRule(manufacturers=MANUFACTURER), True), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), - (registries.MatchRule(quirk_classes=QUIRK_CLASS), True), - (registries.MatchRule(quirk_classes="no match"), False), + (registries.MatchRule(quirk_ids=QUIRK_ID), True), + (registries.MatchRule(quirk_ids="no match"), False), # match everything ( registries.MatchRule( @@ -323,7 +320,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: cluster_handler_names={"on_off", "level"}, manufacturers=MANUFACTURER, models=MODEL, - quirk_classes=QUIRK_CLASS, + quirk_ids=QUIRK_ID, ), True, ), @@ -332,8 +329,7 @@ def test_registry_matching(rule, matched, cluster_handlers) -> None: def test_registry_loose_matching(rule, matched, cluster_handlers) -> None: """Test loose rule matching.""" assert ( - rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_CLASS) - is matched + rule.loose_matched(MANUFACTURER, MODEL, cluster_handlers, QUIRK_ID) is matched ) @@ -397,12 +393,12 @@ def entity_registry(): @pytest.mark.parametrize( - ("manufacturer", "model", "quirk_class", "match_name"), + ("manufacturer", "model", "quirk_id", "match_name"), ( ("random manufacturer", "random model", "random.class", "OnOff"), ("random manufacturer", MODEL, "random.class", "OnOffModel"), (MANUFACTURER, "random model", "random.class", "OnOffManufacturer"), - ("random manufacturer", "random model", QUIRK_CLASS, "OnOffQuirk"), + ("random manufacturer", "random model", QUIRK_ID, "OnOffQuirk"), (MANUFACTURER, MODEL, "random.class", "OnOffModelManufacturer"), (MANUFACTURER, "some model", "random.class", "OnOffMultimodel"), ), @@ -412,7 +408,7 @@ def test_weighted_match( entity_registry: er.EntityRegistry, manufacturer, model, - quirk_class, + quirk_id, match_name, ) -> None: """Test weightedd match.""" @@ -453,7 +449,7 @@ def test_weighted_match( pass @entity_registry.strict_match( - s.component, cluster_handler_names="on_off", quirk_classes=QUIRK_CLASS + s.component, cluster_handler_names="on_off", quirk_ids=QUIRK_ID ) class OnOffQuirk: pass @@ -462,7 +458,7 @@ def test_weighted_match( ch_level = cluster_handler("level", 8) match, claimed = entity_registry.get_entity( - s.component, manufacturer, model, [ch_on_off, ch_level], quirk_class + s.component, manufacturer, model, [ch_on_off, ch_level], quirk_id ) assert match.__name__ == match_name @@ -490,7 +486,7 @@ def test_multi_sensor_match( "manufacturer", "model", cluster_handlers=[ch_se, ch_illuminati], - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -520,7 +516,7 @@ def test_multi_sensor_match( "manufacturer", "model", cluster_handlers={ch_se, ch_illuminati}, - quirk_class="quirk_class", + quirk_id="quirk_id", ) assert s.binary_sensor in match @@ -554,18 +550,10 @@ def iter_all_rules() -> typing.Iterable[registries.MatchRule, list[type[ZhaEntit def test_quirk_classes() -> None: - """Make sure that quirk_classes in components matches are valid.""" - - def find_quirk_class(base_obj, quirk_mod, quirk_cls): - """Find a specific quirk class.""" - - module = importlib.import_module(quirk_mod) - clss = dict(inspect.getmembers(module, inspect.isclass)) - # Check quirk_cls in module classes - return quirk_cls in clss + """Make sure that all quirk IDs in components matches exist.""" def quirk_class_validator(value): - """Validate quirk classes during self test.""" + """Validate quirk IDs during self test.""" if callable(value): # Callables cannot be tested return @@ -576,16 +564,22 @@ def test_quirk_classes() -> None: quirk_class_validator(v) return - quirk_tok = value.rsplit(".", 1) - if len(quirk_tok) != 2: - # quirk_class is at least __module__.__class__ - raise ValueError(f"Invalid quirk class : '{value}'") + if value not in all_quirk_ids: + raise ValueError(f"Quirk ID '{value}' does not exist.") - if not find_quirk_class(zhaquirks, quirk_tok[0], quirk_tok[1]): - raise ValueError(f"Quirk class '{value}' does not exists.") + # get all quirk ID from zigpy quirks registry + all_quirk_ids = [] + for manufacturer in zigpy_quirks._DEVICE_REGISTRY._registry.values(): + for model_quirk_list in manufacturer.values(): + for quirk in model_quirk_list: + quirk_id = getattr(quirk, ATTR_QUIRK_ID, None) + if quirk_id is not None and quirk_id not in all_quirk_ids: + all_quirk_ids.append(quirk_id) + del quirk, model_quirk_list, manufacturer + # validate all quirk IDs used in component match rules for rule, _ in iter_all_rules(): - quirk_class_validator(rule.quirk_classes) + quirk_class_validator(rule.quirk_ids) def test_entity_names() -> None: From 0ce7f44294ef3b43a67992ebac1d1b982648ff03 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:25:14 +0200 Subject: [PATCH 792/968] Use real devices in water_heater device action tests (#102730) --- .../water_heater/test_device_action.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index a8ca41905d6..8254fb77a77 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -118,7 +130,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -130,7 +142,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -157,10 +169,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -174,7 +196,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, From 56ee1753ec681ca46f49badc6a86ab2545b89f5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:26:12 +0200 Subject: [PATCH 793/968] Use real devices in number device action tests (#102724) --- tests/components/number/test_device_action.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 1e0cfd5b391..17c63dd3394 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -155,7 +167,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -178,10 +190,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) @@ -197,7 +219,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, From 4d83cffb39dc5d7d61c9237ce2d96d2406e88d00 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:27:29 +0200 Subject: [PATCH 794/968] Use real devices in alarm_control_panel device condition tests (#102703) --- .../test_device_condition.py | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index f1719b83d38..6e85c94379f 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -185,10 +185,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_triggered", } @@ -223,7 +234,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_disarmed", } @@ -245,7 +256,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_home", } @@ -267,7 +278,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_away", } @@ -289,7 +300,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_night", } @@ -311,7 +322,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_vacation", } @@ -333,7 +344,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_armed_custom_bypass", } @@ -438,10 +449,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls: list[ServiceCall] + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls: list[ServiceCall], ) -> None: """Test for all conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -454,7 +476,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_triggered", } From 9d3cdc85ca315b77e8632401a5254ce4718b4d66 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:28:29 +0200 Subject: [PATCH 795/968] Use real devices in binary_sensor device condition tests (#102704) --- .../binary_sensor/test_device_condition.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index b25ab787791..c902caf31ae 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -234,6 +234,7 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -245,7 +246,14 @@ async def test_if_state( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -258,7 +266,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_bat_low", } @@ -277,7 +285,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", } @@ -312,6 +320,7 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -323,7 +332,14 @@ async def test_if_state_legacy( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -336,7 +352,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_bat_low", } @@ -364,6 +380,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -379,7 +396,14 @@ async def test_if_fires_on_for_condition( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(platform.ENTITIES["battery"].entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) with freeze_time(point1) as time_freeze: assert await async_setup_component( @@ -392,7 +416,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_bat_low", "for": {"seconds": 5}, From bead989e7f6aa5a375fe3f80f67c9883f91003d2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:29:44 +0200 Subject: [PATCH 796/968] Use real devices in climate device condition tests (#102705) --- .../climate/test_device_condition.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 33df78bf347..4dc365e59ee 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -147,10 +147,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -163,7 +174,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_hvac_mode", "hvac_mode": "cool", @@ -185,7 +196,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_preset_mode", "preset_mode": "away", @@ -257,10 +268,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -273,7 +295,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_hvac_mode", "hvac_mode": "cool", From 14485af22ddb70ca1340de16be5be399e4a59731 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:30:33 +0200 Subject: [PATCH 797/968] Use real devices in cover device condition tests (#102706) --- .../components/cover/test_device_condition.py | 68 +++++++++++++++---- 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index bfde3a0b514..2dcc719f35f 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -355,10 +355,21 @@ async def test_get_condition_capabilities_set_tilt_pos( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -373,7 +384,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_open", } @@ -395,7 +406,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closed", } @@ -417,7 +428,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_opening", } @@ -439,7 +450,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_closing", } @@ -487,10 +498,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_OPEN) @@ -505,7 +527,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_open", } @@ -533,6 +555,7 @@ async def test_if_state_legacy( async def test_if_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -545,7 +568,14 @@ async def test_if_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -559,7 +589,7 @@ async def test_if_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -593,7 +623,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "below": 90, @@ -616,7 +646,7 @@ async def test_if_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_position", "above": 45, @@ -686,6 +716,7 @@ async def test_if_position( async def test_if_tilt_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, @@ -698,7 +729,14 @@ async def test_if_tilt_position( assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) await hass.async_block_till_done() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entry = entity_registry.async_get(ent.entity_id) + entity_registry.async_update_entity(entry.entity_id, device_id=device_entry.id) assert await async_setup_component( hass, @@ -712,7 +750,7 @@ async def test_if_tilt_position( "conditions": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, @@ -746,7 +784,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "below": 90, @@ -769,7 +807,7 @@ async def test_if_tilt_position( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_tilt_position", "above": 45, From 9cf9b36637831b8d3a9816e36485c3674ddec5cb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:31:49 +0200 Subject: [PATCH 798/968] Use real devices in device_tracker device condition tests (#102707) --- .../device_tracker/test_device_condition.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 008a7eb75c4..f550b803fda 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_home", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_not_home", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_HOME) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_home", } From 3ed67f134f4c720b904bca87523b6c605ec98910 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:32:56 +0200 Subject: [PATCH 799/968] Use real devices in fan device condition tests (#102708) --- tests/components/fan/test_device_condition.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 20c84eb1436..1ee168f28ab 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -110,10 +110,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -128,7 +139,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -184,10 +195,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -202,7 +224,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } From 0e8bd9805a8880f022c97e5a14eb5a5f7ef99675 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:34:31 +0200 Subject: [PATCH 800/968] Use real devices in humidifier device condition tests (#102709) --- .../humidifier/test_device_condition.py | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index bf8eb98f456..224c69b9fb5 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -149,10 +149,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -167,7 +178,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -205,7 +216,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_mode", "mode": "away", @@ -254,10 +265,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON, {ATTR_MODE: const.MODE_AWAY}) @@ -272,7 +294,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_mode", "mode": "away", From 2049d892babfc033f9492f866f5b5d8571e95c8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:35:34 +0200 Subject: [PATCH 801/968] Use real devices in media_player device condition tests (#102710) --- .../media_player/test_device_condition.py | 44 ++++++++++++++----- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index b89993dec65..ea1f65eab95 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -132,10 +132,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -150,7 +161,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -168,7 +179,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -186,7 +197,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_idle", } @@ -204,7 +215,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_paused", } @@ -222,7 +233,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_playing", } @@ -240,7 +251,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_buffering", } @@ -322,10 +333,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -340,7 +362,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } From 1b61cd9179d8e6470584dd9de91a4020546c2594 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:36:31 +0200 Subject: [PATCH 802/968] Use real devices in remote device condition tests (#102711) --- .../remote/test_device_condition.py | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index b07747771d9..1048aa1b081 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,6 +319,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -310,7 +329,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -325,7 +352,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, From ff60a8072e191c33ab77ec29b2f50d875dbb9b95 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:37:16 +0200 Subject: [PATCH 803/968] Use real devices in select device condition tests (#102712) --- .../select/test_device_condition.py | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/components/select/test_device_condition.py b/tests/components/select/test_device_condition.py index 18ebd428891..3e0ecd6e547 100644 --- a/tests/components/select/test_device_condition.py +++ b/tests/components/select/test_device_condition.py @@ -115,10 +115,19 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_selected_option( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -131,7 +140,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option1", @@ -150,7 +159,7 @@ async def test_if_selected_option( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "selected_option", "option": "option2", @@ -195,10 +204,19 @@ async def test_if_selected_option( async def test_if_selected_option_legacy( hass: HomeAssistant, calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, ) -> None: """Test for selected_option conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -211,7 +229,7 @@ async def test_if_selected_option_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "selected_option", "option": "option1", From b5a6e6b9d5defc174f17c84df9d5ed55d79c3e1a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:37:36 +0200 Subject: [PATCH 804/968] Use real devices in sensor device condition tests (#102713) --- .../sensor/test_device_condition.py | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 301baf0fc49..e0a8bebf5fc 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -461,13 +461,22 @@ async def test_get_condition_capabilities_none( async def test_if_state_not_above_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, caplog: pytest.LogCaptureFixture, enable_custom_integrations: None, ) -> None: """Test for bad value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -480,7 +489,7 @@ async def test_if_state_not_above_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", } @@ -495,12 +504,21 @@ async def test_if_state_not_above_below( async def test_if_state_above( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -515,7 +533,7 @@ async def test_if_state_above( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, @@ -553,12 +571,21 @@ async def test_if_state_above( async def test_if_state_above_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -573,7 +600,7 @@ async def test_if_state_above_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_battery_level", "above": 10, @@ -611,12 +638,21 @@ async def test_if_state_above_legacy( async def test_if_state_below( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -631,7 +667,7 @@ async def test_if_state_below( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "below": 10, @@ -669,12 +705,21 @@ async def test_if_state_below( async def test_if_state_between( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for value conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_UNKNOWN, {"device_class": "battery"}) @@ -689,7 +734,7 @@ async def test_if_state_between( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_battery_level", "above": 10, From e761d5715b753876912d7540ad72cc41e814f8c8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:37:48 +0200 Subject: [PATCH 805/968] Use real devices in switch device condition tests (#102714) --- .../switch/test_device_condition.py | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index c60954e335f..c9521930a73 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -300,6 +318,7 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, @@ -309,7 +328,15 @@ async def test_if_fires_on_for_condition( point2 = point1 + timedelta(seconds=10) point3 = point2 + timedelta(seconds=10) - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -324,7 +351,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, From e708faa4d61be916f0c5fc1e55b080ae48417664 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:38:01 +0200 Subject: [PATCH 806/968] Use real devices in vacuum device condition tests (#102715) --- .../vacuum/test_device_condition.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 694f4b64417..a2ba75cc752 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -115,10 +115,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_DOCKED) @@ -133,7 +144,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_cleaning", } @@ -151,7 +162,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_docked", } @@ -189,10 +200,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_CLEANING) @@ -207,7 +229,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_cleaning", } From 69ce85d5aff67779afa7d3d593cb49d615a84dae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:38:19 +0200 Subject: [PATCH 807/968] Use real devices in select device action tests (#102726) --- tests/components/select/test_device_action.py | 67 +++++++++++++++---- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/tests/components/select/test_device_action.py b/tests/components/select/test_device_action.py index ce5d48bb358..121b41fcb2b 100644 --- a/tests/components/select/test_device_action.py +++ b/tests/components/select/test_device_action.py @@ -116,10 +116,21 @@ async def test_get_actions_hidden_auxiliary( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -133,7 +144,7 @@ async def test_action_select_first_last( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, }, @@ -154,10 +165,21 @@ async def test_action_select_first_last( @pytest.mark.parametrize("action_type", ("select_first", "select_last")) async def test_action_select_first_last_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_first and select_last actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -171,7 +193,7 @@ async def test_action_select_first_last_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": action_type, }, @@ -191,10 +213,20 @@ async def test_action_select_first_last_legacy( async def test_action_select_option( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for select_option action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -208,7 +240,7 @@ async def test_action_select_option( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "select_option", "option": "option1", @@ -230,10 +262,21 @@ async def test_action_select_option( @pytest.mark.parametrize("action_type", ["select_next", "select_previous"]) async def test_action_select_next_previous( - hass: HomeAssistant, entity_registry: er.EntityRegistry, action_type: str + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + action_type: str, ) -> None: """Test for select_next and select_previous actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -247,7 +290,7 @@ async def test_action_select_next_previous( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": action_type, "cycle": False, From 02a83740cccdaf3d45413a1939bf919631028246 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:38:24 +0200 Subject: [PATCH 808/968] Use real devices in light device action tests (#102722) --- tests/components/light/test_device_action.py | 40 ++++++++++++++------ 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 05483b46d97..3b60b886b02 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -467,12 +467,21 @@ async def test_get_action_capabilities_features_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -483,7 +492,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -492,7 +501,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -501,7 +510,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -510,7 +519,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_short"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", }, @@ -519,7 +528,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_flash_long"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "flash", "flash": "long", @@ -532,7 +541,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_increase", }, @@ -544,7 +553,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "brightness_decrease", }, @@ -553,7 +562,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_brightness"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", "brightness_pct": 75, @@ -623,12 +632,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -639,7 +657,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, From e1394d720fc60dbea43dd68d2dfe78a7acb143c5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:38:35 +0200 Subject: [PATCH 809/968] Use real devices in vacuum device action tests (#102729) --- tests/components/vacuum/test_device_action.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 617b8d41609..cf0ab3c20d8 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -102,9 +102,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -115,7 +127,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "dock", }, @@ -124,7 +136,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_clean"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "clean", }, @@ -151,10 +163,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -165,7 +187,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_dock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "dock", }, From 2e9a3e8c8ef2ccbe599a6dcc4482d6a12d59ebc2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:39:14 +0200 Subject: [PATCH 810/968] Use real devices in humidifier device action tests (#102721) --- .../humidifier/test_device_action.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 600be154fc7..ff508bd3a67 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -142,9 +142,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -164,7 +176,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -176,7 +188,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -185,7 +197,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_toggle"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -197,7 +209,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_humidity", "humidity": 35, @@ -210,7 +222,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_mode", "mode": const.MODE_AWAY, @@ -290,10 +302,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -313,7 +335,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_mode", "mode": const.MODE_AWAY, From a4487637efc3a2e93ebd9bf93332ed3c2cc26baf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:39:37 +0200 Subject: [PATCH 811/968] Use real devices in alarm_control_panel device action tests (#102716) --- .../alarm_control_panel/test_device_action.py | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 8ba196de545..08ccef37336 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -426,6 +426,7 @@ async def test_get_action_capabilities_arm_code_legacy( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -433,10 +434,17 @@ async def test_action( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -451,7 +459,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_away", }, @@ -463,7 +471,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_home", }, @@ -475,7 +483,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_night", }, @@ -487,7 +495,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "arm_vacation", }, @@ -496,7 +504,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_disarm"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "disarm", "code": "1234", @@ -509,7 +517,7 @@ async def test_action( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.id, "type": "trigger", }, @@ -549,6 +557,7 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: @@ -556,10 +565,17 @@ async def test_action_legacy( platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) entity_entry = entity_registry.async_get_or_create( DOMAIN, "test", platform.ENTITIES["no_arm_code"].unique_id, + device_id=device_entry.id, ) assert await async_setup_component( @@ -574,7 +590,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entity_entry.entity_id, "type": "arm_away", }, From 4bb6787909de624509000da493ba84148cd06609 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:39:47 +0200 Subject: [PATCH 812/968] Use real devices in button device action tests (#102717) --- tests/components/button/test_device_action.py | 34 +++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/components/button/test_device_action.py b/tests/components/button/test_device_action.py index 43e2d3f855f..3fefa580724 100644 --- a/tests/components/button/test_device_action.py +++ b/tests/components/button/test_device_action.py @@ -95,9 +95,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -111,7 +123,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "press", }, @@ -131,10 +143,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for press action.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -148,7 +170,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "press", }, From 8da421c442571153ca7b38554bc8af6fe55e77c9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:40:07 +0200 Subject: [PATCH 813/968] Use real devices in climate device action tests (#102718) --- .../components/climate/test_device_action.py | 36 +++++++++++++++---- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index f56f499c935..8ef73ed4e51 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -143,9 +143,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -168,7 +180,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, @@ -181,7 +193,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_preset_mode", "preset_mode": const.PRESET_AWAY, @@ -219,10 +231,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, @@ -245,7 +267,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_hvac_mode", "hvac_mode": HVACMode.OFF, From 21d0fa640f133212e48446f3277d2c8670089dc6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:40:22 +0200 Subject: [PATCH 814/968] Use real devices in cover device action tests (#102719) --- tests/components/cover/test_device_action.py | 60 ++++++++++++++++---- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 0cc6716bd3c..c476f78702e 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -351,11 +351,20 @@ async def test_get_action_capabilities_set_tilt_pos( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -366,7 +375,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -375,7 +384,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close", }, @@ -384,7 +393,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_event_stop"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "stop", }, @@ -429,11 +438,20 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -444,7 +462,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -467,11 +485,20 @@ async def test_action_legacy( async def test_action_tilt( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover tilt actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -482,7 +509,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open_tilt", }, @@ -491,7 +518,7 @@ async def test_action_tilt( "trigger": {"platform": "event", "event_type": "test_event_close"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "close_tilt", }, @@ -529,11 +556,20 @@ async def test_action_tilt( async def test_action_set_position( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, enable_custom_integrations: None, ) -> None: """Test for cover set position actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -547,7 +583,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_position", "position": 25, @@ -560,7 +596,7 @@ async def test_action_set_position( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_tilt_position", "position": 75, From 530611c44e2ffba19457dd584802e639b3d3de21 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:40:24 +0200 Subject: [PATCH 815/968] Use real devices in fan device action tests (#102720) --- tests/components/fan/test_device_action.py | 38 +++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 5404c80340e..3b179bc158c 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -103,9 +103,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -119,7 +131,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -131,7 +143,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -143,7 +155,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -176,10 +188,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +215,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, From 3a11a6f973d651ff03c13e51a0b211ff9b909071 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:43:30 +0200 Subject: [PATCH 816/968] Use real devices in switch device action tests (#102727) --- tests/components/switch/test_device_action.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index 85799a49a34..e86c32c1e32 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -111,12 +111,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -127,7 +136,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -136,7 +145,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -145,7 +154,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -177,12 +186,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -193,7 +211,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, From f56343f4470161370d6139e1c3518e05d249cd4f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 24 Oct 2023 23:43:50 +0200 Subject: [PATCH 817/968] Use real devices in lock device action tests (#102723) --- tests/components/lock/test_device_action.py | 38 ++++++++++++++++----- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index f87fa4cc178..1e451920baf 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -136,9 +136,21 @@ async def test_get_actions_hidden_auxiliary( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -149,7 +161,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, @@ -158,7 +170,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_unlock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "unlock", }, @@ -167,7 +179,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - "trigger": {"platform": "event", "event_type": "test_event_open"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "open", }, @@ -211,10 +223,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for lock actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -225,7 +247,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_event_lock"}, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "lock", }, From b37253b2060feca5e9b555ce3347c27c6a3dc551 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 24 Oct 2023 21:45:20 +0000 Subject: [PATCH 818/968] Bump `gios` to version 3.2.0 (#102675) --- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 41954645f5c..fece0b09f60 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["dacite", "gios"], "quality_scale": "platinum", - "requirements": ["gios==3.1.0"] + "requirements": ["gios==3.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c31b83d4e57..4b1009b5c50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -882,7 +882,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47a89037a77..5731fe15bff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -704,7 +704,7 @@ georss-qld-bushfire-alert-client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==3.1.0 +gios==3.2.0 # homeassistant.components.glances glances-api==0.4.3 From a691bd26cfb8e18961efced37f53a6895a88822b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 25 Oct 2023 00:32:20 +0200 Subject: [PATCH 819/968] Support Lidl christmas light effects in deCONZ (#102731) --- homeassistant/components/deconz/light.py | 43 +++++++++++++++++++++++- tests/components/deconz/test_light.py | 20 ++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 47ca1eda0d8..dc2ed04b4ed 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -38,7 +38,27 @@ from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_GROUP = "is_deconz_group" -EFFECT_TO_DECONZ = {EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, "None": LightEffect.NONE} +EFFECT_TO_DECONZ = { + EFFECT_COLORLOOP: LightEffect.COLOR_LOOP, + "None": LightEffect.NONE, + # Specific to Lidl christmas light + "carnival": LightEffect.CARNIVAL, + "collide": LightEffect.COLLIDE, + "fading": LightEffect.FADING, + "fireworks": LightEffect.FIREWORKS, + "flag": LightEffect.FLAG, + "glow": LightEffect.GLOW, + "rainbow": LightEffect.RAINBOW, + "snake": LightEffect.SNAKE, + "snow": LightEffect.SNOW, + "sparkles": LightEffect.SPARKLES, + "steady": LightEffect.STEADY, + "strobe": LightEffect.STROBE, + "twinkle": LightEffect.TWINKLE, + "updown": LightEffect.UPDOWN, + "vintage": LightEffect.VINTAGE, + "waves": LightEffect.WAVES, +} FLASH_TO_DECONZ = {FLASH_SHORT: LightAlert.SHORT, FLASH_LONG: LightAlert.LONG} DECONZ_TO_COLOR_MODE = { @@ -47,6 +67,25 @@ DECONZ_TO_COLOR_MODE = { LightColorMode.XY: ColorMode.XY, } +TS0601_EFFECTS = [ + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", +] + _LightDeviceT = TypeVar("_LightDeviceT", bound=Group | Light) @@ -161,6 +200,8 @@ class DeconzBaseLight(DeconzDevice[_LightDeviceT], LightEntity): if device.effect is not None: self._attr_supported_features |= LightEntityFeature.EFFECT self._attr_effect_list = [EFFECT_COLORLOOP] + if device.model_id == "TS0601": + self._attr_effect_list += TS0601_EFFECTS @property def color_mode(self) -> str | None: diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index f6c4452dac6..357371e4853 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -185,7 +185,25 @@ async def test_no_lights_or_groups( "entity_id": "light.lidl_xmas_light", "state": STATE_ON, "attributes": { - ATTR_EFFECT_LIST: [EFFECT_COLORLOOP], + ATTR_EFFECT_LIST: [ + EFFECT_COLORLOOP, + "carnival", + "collide", + "fading", + "fireworks", + "flag", + "glow", + "rainbow", + "snake", + "snow", + "sparkles", + "steady", + "strobe", + "twinkle", + "updown", + "vintage", + "waves", + ], ATTR_SUPPORTED_COLOR_MODES: [ColorMode.HS], ATTR_COLOR_MODE: ColorMode.HS, ATTR_BRIGHTNESS: 25, From f91583a0fc6aaa2e1621c7f9f1b169d288edc45e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Oct 2023 18:40:39 -0500 Subject: [PATCH 820/968] Add support for family to aiohttp session helper (#102702) --- homeassistant/helpers/aiohttp_client.py | 51 ++++++++--- tests/components/demo/test_media_player.py | 4 +- tests/helpers/test_aiohttp_client.py | 100 ++++++++++++++------- 3 files changed, 106 insertions(+), 49 deletions(-) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 20351efff53..b8d810d899b 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -7,7 +7,7 @@ from contextlib import suppress from ssl import SSLContext import sys from types import MappingProxyType -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any import aiohttp from aiohttp import web @@ -29,9 +29,8 @@ if TYPE_CHECKING: DATA_CONNECTOR = "aiohttp_connector" -DATA_CONNECTOR_NOTVERIFY = "aiohttp_connector_notverify" DATA_CLIENTSESSION = "aiohttp_clientsession" -DATA_CLIENTSESSION_NOTVERIFY = "aiohttp_clientsession_notverify" + SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) @@ -88,22 +87,31 @@ class HassClientResponse(aiohttp.ClientResponse): @callback @bind_hass def async_get_clientsession( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.ClientSession: """Return default aiohttp ClientSession. This method must be run in the event loop. """ - key = DATA_CLIENTSESSION if verify_ssl else DATA_CLIENTSESSION_NOTVERIFY + session_key = _make_key(verify_ssl, family) + if DATA_CLIENTSESSION not in hass.data: + sessions: dict[tuple[bool, int], aiohttp.ClientSession] = {} + hass.data[DATA_CLIENTSESSION] = sessions + else: + sessions = hass.data[DATA_CLIENTSESSION] - if key not in hass.data: - hass.data[key] = _async_create_clientsession( + if session_key not in sessions: + session = _async_create_clientsession( hass, verify_ssl, auto_cleanup_method=_async_register_default_clientsession_shutdown, + family=family, ) + sessions[session_key] = session + else: + session = sessions[session_key] - return cast(aiohttp.ClientSession, hass.data[key]) + return session @callback @@ -112,6 +120,7 @@ def async_create_clientsession( hass: HomeAssistant, verify_ssl: bool = True, auto_cleanup: bool = True, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies. @@ -131,6 +140,7 @@ def async_create_clientsession( hass, verify_ssl, auto_cleanup_method=auto_cleanup_method, + family=family, **kwargs, ) @@ -143,11 +153,12 @@ def _async_create_clientsession( verify_ssl: bool = True, auto_cleanup_method: Callable[[HomeAssistant, aiohttp.ClientSession], None] | None = None, + family: int = 0, **kwargs: Any, ) -> aiohttp.ClientSession: """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( - connector=_async_get_connector(hass, verify_ssl), + connector=_async_get_connector(hass, verify_ssl, family), json_serialize=json_dumps, response_class=HassClientResponse, **kwargs, @@ -275,18 +286,29 @@ def _async_register_default_clientsession_shutdown( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, _async_close_websession) +@callback +def _make_key(verify_ssl: bool = True, family: int = 0) -> tuple[bool, int]: + """Make a key for connector or session pool.""" + return (verify_ssl, family) + + @callback def _async_get_connector( - hass: HomeAssistant, verify_ssl: bool = True + hass: HomeAssistant, verify_ssl: bool = True, family: int = 0 ) -> aiohttp.BaseConnector: """Return the connector pool for aiohttp. This method must be run in the event loop. """ - key = DATA_CONNECTOR if verify_ssl else DATA_CONNECTOR_NOTVERIFY + connector_key = _make_key(verify_ssl, family) + if DATA_CONNECTOR not in hass.data: + connectors: dict[tuple[bool, int], aiohttp.BaseConnector] = {} + hass.data[DATA_CONNECTOR] = connectors + else: + connectors = hass.data[DATA_CONNECTOR] - if key in hass.data: - return cast(aiohttp.BaseConnector, hass.data[key]) + if connector_key in connectors: + return connectors[connector_key] if verify_ssl: ssl_context: bool | SSLContext = ssl_util.get_default_context() @@ -294,12 +316,13 @@ def _async_get_connector( ssl_context = ssl_util.get_default_no_verify_context() connector = aiohttp.TCPConnector( + family=family, enable_cleanup_closed=ENABLE_CLEANUP_CLOSED, ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, ) - hass.data[key] = connector + connectors[connector_key] = connector async def _async_close_connector(event: Event) -> None: """Close connector pool.""" diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index ff6274af1b5..b1bd77a74a1 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -16,7 +16,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION +from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION, _make_key from homeassistant.setup import async_setup_component from tests.typing import ClientSessionGenerator @@ -483,7 +483,7 @@ async def test_media_image_proxy( def detach(self): """Test websession detach.""" - hass.data[DATA_CLIENTSESSION] = MockWebsession() + hass.data[DATA_CLIENTSESSION] = {_make_key(): MockWebsession()} state = hass.states.get(TEST_ENTITY_ID) assert state.state == STATE_PLAYING diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index daeb324b19f..46b389722e8 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -52,26 +52,53 @@ def camera_client_fixture(hass, hass_client): async def test_get_clientsession_with_ssl(hass: HomeAssistant) -> None: """Test init clientsession with ssl.""" client.async_get_clientsession(hass) + verify_ssl = True + family = 0 - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_get_clientsession_without_ssl(hass: HomeAssistant) -> None: """Test init clientsession without ssl.""" client.async_get_clientsession(hass, verify_ssl=False) + verify_ssl = False + family = 0 - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) + + +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession combinations.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_with_ssl_and_cookies(hass: HomeAssistant) -> None: """Test create clientsession with ssl.""" session = client.async_create_clientsession(hass, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + + verify_ssl = True + family = 0 + + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) async def test_create_clientsession_without_ssl_and_cookies( @@ -80,46 +107,53 @@ async def test_create_clientsession_without_ssl_and_cookies( """Test create clientsession without ssl.""" session = client.async_create_clientsession(hass, False, cookies={"bla": True}) assert isinstance(session, aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) + + verify_ssl = False + family = 0 + + assert client.DATA_CLIENTSESSION not in hass.data + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, family)] + assert isinstance(connector, aiohttp.TCPConnector) -async def test_get_clientsession_cleanup(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass) +@pytest.mark.parametrize( + ("verify_ssl", "expected_family"), + [(True, 0), (False, 0), (True, 4), (False, 4), (True, 6), (False, 6)], +) +async def test_get_clientsession_cleanup( + hass: HomeAssistant, verify_ssl: bool, expected_family: int +) -> None: + """Test init clientsession cleanup.""" + client.async_get_clientsession(hass, verify_ssl=verify_ssl, family=expected_family) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + client_session = hass.data[client.DATA_CLIENTSESSION][(verify_ssl, expected_family)] + assert isinstance(client_session, aiohttp.ClientSession) + connector = hass.data[client.DATA_CONNECTOR][(verify_ssl, expected_family)] + assert isinstance(connector, aiohttp.TCPConnector) hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await hass.async_block_till_done() - assert hass.data[client.DATA_CLIENTSESSION].closed - assert hass.data[client.DATA_CONNECTOR].closed - - -async def test_get_clientsession_cleanup_without_ssl(hass: HomeAssistant) -> None: - """Test init clientsession with ssl.""" - client.async_get_clientsession(hass, verify_ssl=False) - - assert isinstance( - hass.data[client.DATA_CLIENTSESSION_NOTVERIFY], aiohttp.ClientSession - ) - assert isinstance(hass.data[client.DATA_CONNECTOR_NOTVERIFY], aiohttp.TCPConnector) - - hass.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) - await hass.async_block_till_done() - - assert hass.data[client.DATA_CLIENTSESSION_NOTVERIFY].closed - assert hass.data[client.DATA_CONNECTOR_NOTVERIFY].closed + assert client_session.closed + assert connector.closed async def test_get_clientsession_patched_close(hass: HomeAssistant) -> None: """Test closing clientsession does not work.""" + + verify_ssl = True + family = 0 + with patch("aiohttp.ClientSession.close") as mock_close: session = client.async_get_clientsession(hass) - assert isinstance(hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession) - assert isinstance(hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector) + assert isinstance( + hass.data[client.DATA_CLIENTSESSION][(verify_ssl, family)], + aiohttp.ClientSession, + ) + assert isinstance( + hass.data[client.DATA_CONNECTOR][(verify_ssl, family)], aiohttp.TCPConnector + ) with pytest.raises(RuntimeError): await session.close() From a1a5713e10b76b98b36b14d821ca50a3af37d27e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 02:14:23 +0200 Subject: [PATCH 821/968] Abort Improv via BLE bluetooth flow if device is provisioned (#102656) Co-authored-by: J. Nick Koston --- .../components/improv_ble/config_flow.py | 108 ++++++++----- .../components/improv_ble/strings.json | 1 - .../components/improv_ble/test_config_flow.py | 142 +++++------------- 3 files changed, 103 insertions(+), 148 deletions(-) diff --git a/homeassistant/components/improv_ble/config_flow.py b/homeassistant/components/improv_ble/config_flow.py index 0ed2becc1f1..bfc86ac0162 100644 --- a/homeassistant/components/improv_ble/config_flow.py +++ b/homeassistant/components/improv_ble/config_flow.py @@ -20,13 +20,10 @@ from improv_ble_client import ( import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.bluetooth import ( - BluetoothServiceInfoBleak, - async_discovered_service_info, - async_last_service_info, -) +from homeassistant.components import bluetooth from homeassistant.const import CONF_ADDRESS -from homeassistant.data_entry_flow import FlowResult +from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from .const import DOMAIN @@ -42,14 +39,6 @@ STEP_PROVISION_SCHEMA = vol.Schema( ) -class AbortFlow(Exception): - """Raised when a flow should be aborted.""" - - def __init__(self, reason: str) -> None: - """Initialize.""" - self.reason = reason - - @dataclass class Credentials: """Container for WiFi credentials.""" @@ -69,15 +58,16 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _provision_result: FlowResult | None = None _provision_task: asyncio.Task | None = None _reauth_entry: config_entries.ConfigEntry | None = None + _remove_bluetooth_callback: Callable[[], None] | None = None _unsub: Callable[[], None] | None = None def __init__(self) -> None: """Initialize the config flow.""" self._device: ImprovBLEClient | None = None # Populated by user step - self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + self._discovered_devices: dict[str, bluetooth.BluetoothServiceInfoBleak] = {} # Populated by bluetooth, reauth_confirm and user steps - self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovery_info: bluetooth.BluetoothServiceInfoBleak | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -95,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_start_improv() current_addresses = self._async_current_ids() - for discovery in async_discovered_service_info(self.hass): + for discovery in bluetooth.async_discovered_service_info(self.hass): if ( discovery.address in current_addresses or discovery.address in self._discovered_devices @@ -125,22 +115,66 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_bluetooth( - self, discovery_info: BluetoothServiceInfoBleak - ) -> FlowResult: - """Handle the Bluetooth discovery step.""" - await self.async_set_unique_id(discovery_info.address) - self._abort_if_unique_id_configured() - service_data = discovery_info.service_data + def _abort_if_provisioned(self) -> None: + """Check improv state and abort flow if needed.""" + # mypy is not aware that we can't get here without having these set already + assert self._discovery_info is not None + + service_data = self._discovery_info.service_data improv_service_data = ImprovServiceData.from_bytes( service_data[SERVICE_DATA_UUID] ) if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): _LOGGER.debug( - "Device is already provisioned: %s", improv_service_data.state + "Aborting improv flow, device is already provisioned: %s", + improv_service_data.state, ) - return self.async_abort(reason="already_provisioned") + raise AbortFlow("already_provisioned") + + @callback + def _async_update_ble( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update from a ble callback.""" + _LOGGER.debug( + "Got updated BLE data: %s", + service_info.service_data[SERVICE_DATA_UUID].hex(), + ) + + self._discovery_info = service_info + try: + self._abort_if_provisioned() + except AbortFlow: + self.hass.config_entries.flow.async_abort(self.flow_id) + + def _unregister_bluetooth_callback(self) -> None: + """Unregister bluetooth callbacks.""" + if not self._remove_bluetooth_callback: + return + self._remove_bluetooth_callback() + self._remove_bluetooth_callback = None + + async def async_step_bluetooth( + self, discovery_info: bluetooth.BluetoothServiceInfoBleak + ) -> FlowResult: + """Handle the Bluetooth discovery step.""" self._discovery_info = discovery_info + + await self.async_set_unique_id(discovery_info.address) + self._abort_if_unique_id_configured() + self._abort_if_provisioned() + + self._remove_bluetooth_callback = bluetooth.async_register_callback( + self.hass, + self._async_update_ble, + bluetooth.BluetoothCallbackMatcher( + {bluetooth.match.ADDRESS: discovery_info.address} + ), + bluetooth.BluetoothScanningMode.PASSIVE, + ) + name = self._discovery_info.name or self._discovery_info.address self.context["title_placeholders"] = {"name": name} return await self.async_step_bluetooth_confirm() @@ -159,6 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"name": name}, ) + self._unregister_bluetooth_callback() return await self.async_step_start_improv() async def async_step_start_improv( @@ -171,23 +206,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """ # mypy is not aware that we can't get here without having these set already assert self._discovery_info is not None - discovery_info = self._discovery_info = async_last_service_info( - self.hass, self._discovery_info.address - ) - if not discovery_info: - return self.async_abort(reason="cannot_connect") - service_data = discovery_info.service_data - improv_service_data = ImprovServiceData.from_bytes( - service_data[SERVICE_DATA_UUID] - ) - if improv_service_data.state in (State.PROVISIONING, State.PROVISIONED): - _LOGGER.debug( - "Device is already provisioned: %s", improv_service_data.state - ) - return self.async_abort(reason="already_provisioned") if not self._device: - self._device = ImprovBLEClient(discovery_info.device) + self._device = ImprovBLEClient(self._discovery_info.device) device = self._device if self._can_identify is None: @@ -387,3 +408,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except Exception as err: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") raise AbortFlow("unknown") from err + + @callback + def async_remove(self) -> None: + """Notification that the flow has been removed.""" + self._unregister_bluetooth_callback() diff --git a/homeassistant/components/improv_ble/strings.json b/homeassistant/components/improv_ble/strings.json index 48b13f6b782..b5713910134 100644 --- a/homeassistant/components/improv_ble/strings.json +++ b/homeassistant/components/improv_ble/strings.json @@ -38,7 +38,6 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "already_provisioned": "The device is already connected to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "characteristic_missing": "The device is either already connected to Wi-Fi, or no longer able to connect to Wi-Fi. If you want to connect it to another network, try factory resetting it first.", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", diff --git a/tests/components/improv_ble/test_config_flow.py b/tests/components/improv_ble/test_config_flow.py index 60228b409c2..f0c77c9bce3 100644 --- a/tests/components/improv_ble/test_config_flow.py +++ b/tests/components/improv_ble/test_config_flow.py @@ -7,6 +7,7 @@ from improv_ble_client import Error, State, errors as improv_ble_errors import pytest from homeassistant import config_entries +from homeassistant.components.bluetooth import BluetoothChange from homeassistant.components.improv_ble.const import DOMAIN from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant @@ -36,7 +37,7 @@ async def test_user_step_success( ) -> None: """Test user step success path.""" with patch( - f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], ): result = await hass.config_entries.flow.async_init( @@ -46,24 +47,15 @@ async def test_user_step_success( assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - await _test_common_success_wo_identify( - hass, - result, - IMPROV_BLE_DISCOVERY_INFO.address, - url, - abort_reason, - placeholders, - ) + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address, url, abort_reason, placeholders + ) async def test_user_step_success_authorize(hass: HomeAssistant) -> None: """Test user step success path.""" with patch( - f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", return_value=[NOT_IMPROV_BLE_DISCOVERY_INFO, IMPROV_BLE_DISCOVERY_INFO], ): result = await hass.config_entries.flow.async_init( @@ -73,19 +65,15 @@ async def test_user_step_success_authorize(hass: HomeAssistant) -> None: assert result["step_id"] == "user" assert result["errors"] == {} - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - await _test_common_success_wo_identify_w_authorize( - hass, result, IMPROV_BLE_DISCOVERY_INFO.address - ) + await _test_common_success_wo_identify_w_authorize( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: """Test user step with no devices found.""" with patch( - f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", return_value=[ PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, NOT_IMPROV_BLE_DISCOVERY_INFO, @@ -111,7 +99,7 @@ async def test_async_step_user_takes_precedence_over_discovery( assert result["step_id"] == "bluetooth_confirm" with patch( - f"{IMPROV_BLE}.config_flow.async_discovered_service_info", + f"{IMPROV_BLE}.config_flow.bluetooth.async_discovered_service_info", return_value=[IMPROV_BLE_DISCOVERY_INFO], ): result = await hass.config_entries.flow.async_init( @@ -120,13 +108,9 @@ async def test_async_step_user_takes_precedence_over_discovery( ) assert result["type"] == FlowResultType.FORM - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - await _test_common_success_wo_identify( - hass, result, IMPROV_BLE_DISCOVERY_INFO.address - ) + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) # Verify the discovery flow was aborted assert not hass.config_entries.flow.async_progress(DOMAIN) @@ -143,48 +127,25 @@ async def test_bluetooth_step_provisioned_device(hass: HomeAssistant) -> None: assert result["reason"] == "already_provisioned" -async def test_bluetooth_confirm_provisioned_device(hass: HomeAssistant) -> None: - """Test bluetooth confirm step when device is already provisioned.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=IMPROV_BLE_DISCOVERY_INFO, - ) +async def test_bluetooth_step_provisioned_device_2(hass: HomeAssistant) -> None: + """Test bluetooth step when device changes to provisioned.""" + with patch( + f"{IMPROV_BLE}.config_flow.bluetooth.async_register_callback", + ) as mock_async_register_callback: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_BLUETOOTH}, + data=IMPROV_BLE_DISCOVERY_INFO, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "bluetooth_confirm" - assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_provisioned" + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 1 + callback = mock_async_register_callback.call_args.args[1] + callback(PROVISIONED_IMPROV_BLE_DISCOVERY_INFO, BluetoothChange.ADVERTISEMENT) -async def test_bluetooth_confirm_lost_device(hass: HomeAssistant) -> None: - """Test bluetooth confirm step when device can no longer be connected to.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_BLUETOOTH}, - data=IMPROV_BLE_DISCOVERY_INFO, - ) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "bluetooth_confirm" - assert result["errors"] is None - - with patch( - f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=None, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert len(hass.config_entries.flow.async_progress_by_handler("improv_ble")) == 0 async def test_bluetooth_step_success(hass: HomeAssistant) -> None: @@ -198,13 +159,9 @@ async def test_bluetooth_step_success(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - await _test_common_success_wo_identify( - hass, result, IMPROV_BLE_DISCOVERY_INFO.address - ) + await _test_common_success_wo_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) async def test_bluetooth_step_success_identify(hass: HomeAssistant) -> None: @@ -218,13 +175,9 @@ async def test_bluetooth_step_success_identify(hass: HomeAssistant) -> None: assert result["step_id"] == "bluetooth_confirm" assert result["errors"] is None - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - await _test_common_success_with_identify( - hass, result, IMPROV_BLE_DISCOVERY_INFO.address - ) + await _test_common_success_with_identify( + hass, result, IMPROV_BLE_DISCOVERY_INFO.address + ) async def _test_common_success_with_identify( @@ -442,9 +395,6 @@ async def test_can_identify_fails(hass: HomeAssistant, exc, error) -> None: with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", side_effect=exc - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -480,9 +430,6 @@ async def test_identify_fails(hass: HomeAssistant, exc, error) -> None: with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=True - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -526,9 +473,6 @@ async def test_need_authorization_fails(hass: HomeAssistant, exc, error) -> None with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -573,9 +517,6 @@ async def test_authorize_fails(hass: HomeAssistant, exc, error) -> None: with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.can_identify", return_value=False - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -656,11 +597,7 @@ async def _test_provision_error(hass: HomeAssistant, exc) -> None: ) async def test_provision_fails(hass: HomeAssistant, exc, error) -> None: """Test bluetooth flow with error.""" - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - flow_id = await _test_provision_error(hass, exc) + flow_id = await _test_provision_error(hass, exc) result = await hass.config_entries.flow.async_configure(flow_id) assert result["type"] == FlowResultType.ABORT @@ -683,9 +620,6 @@ async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None with patch( f"{IMPROV_BLE}.config_flow.ImprovBLEClient.subscribe_state_updates", side_effect=subscribe_state_updates, - ), patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, ): flow_id = await _test_provision_error(hass, exc) result = await hass.config_entries.flow.async_configure(flow_id) @@ -705,11 +639,7 @@ async def test_provision_not_authorized(hass: HomeAssistant, exc, error) -> None ) async def test_provision_retry(hass: HomeAssistant, exc, error) -> None: """Test bluetooth flow with error.""" - with patch( - f"{IMPROV_BLE}.config_flow.async_last_service_info", - return_value=IMPROV_BLE_DISCOVERY_INFO, - ): - flow_id = await _test_provision_error(hass, exc) + flow_id = await _test_provision_error(hass, exc) result = await hass.config_entries.flow.async_configure(flow_id) assert result["type"] == FlowResultType.FORM From eb52943d276b0713961f9ec439c32b55b43f60ac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Oct 2023 02:44:51 +0200 Subject: [PATCH 822/968] Update pytest to 7.4.3 (#102744) --- pyproject.toml | 3 --- requirements_test.txt | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ee3860da30b..82bb7d08e26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -491,9 +491,6 @@ filterwarnings = [ "ignore:`jaraco.functools.call_aside` is deprecated, use `jaraco.functools.invoke` instead:DeprecationWarning:jaraco.abode.helpers.timeline", # https://github.com/nextcord/nextcord/pull/1095 - >2.6.1 "ignore:pkg_resources is deprecated as an API:DeprecationWarning:nextcord.health_check", - # https://github.com/pytest-dev/pytest/pull/10894 - >=7.4.0 - "ignore:ast.(Str|Num|NameConstant) is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", - "ignore:Attribute s is deprecated and will be removed in Python 3.14:DeprecationWarning:_pytest.assertion.rewrite", # https://github.com/bachya/pytile/pull/280 - >2023.08.0 "ignore:datetime.*utcfromtimestamp\\(\\) is deprecated and scheduled for removal:DeprecationWarning:pytile.tile", # https://github.com/rytilahti/python-miio/pull/1809 - >0.5.12 diff --git a/requirements_test.txt b/requirements_test.txt index 69f8936b18b..1dc9139fde7 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-timeout==2.1.0 pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 -pytest==7.3.1 +pytest==7.4.3 requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 From 40817dabbf6132d44a66e2758f9da6ee7cfd91e1 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 25 Oct 2023 03:27:42 +0200 Subject: [PATCH 823/968] Bump aiounifi to v64 (#102700) --- homeassistant/components/unifi/controller.py | 2 +- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_controller.py | 5 ++++ tests/components/unifi/test_switch.py | 26 ++++++++++---------- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 108ff87d026..b89e64f285f 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -263,7 +263,7 @@ class UniFiController: if entry.domain == Platform.DEVICE_TRACKER: macs.append(entry.unique_id.split("-", 1)[0]) - for mac in self.option_block_clients + macs: + for mac in self.option_supported_clients + self.option_block_clients + macs: if mac not in self.api.clients and mac in self.api.clients_all: self.api.clients.process_raw([dict(self.api.clients_all[mac].raw)]) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 7673402aaac..f1fc4777467 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==63"], + "requirements": ["aiounifi==64"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 4b1009b5c50..ac15d831ee1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -369,7 +369,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5731fe15bff..ffa9cc53aa9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,7 +344,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==63 +aiounifi==64 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index 93b39d2fdf2..9d4bde2d016 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -167,6 +167,11 @@ def mock_default_unifi_requests( json={"data": wlans_response or [], "meta": {"rc": "ok"}}, headers={"content-type": CONTENT_TYPE_JSON}, ) + aioclient_mock.get( + f"https://{host}:1234/v2/api/site/{site_id}/trafficrules", + json=[{}], + headers={"content-type": CONTENT_TYPE_JSON}, + ) async def setup_unifi_integration( diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index a08cf0be688..cfcfbe6c3ed 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -771,7 +771,7 @@ async def test_no_clients( }, ) - assert aioclient_mock.call_count == 11 + assert aioclient_mock.call_count == 12 assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0 @@ -860,8 +860,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -869,8 +869,8 @@ async def test_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } @@ -887,8 +887,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 14 - assert aioclient_mock.mock_calls[13][2] == {"enabled": False} + assert aioclient_mock.call_count == 15 + assert aioclient_mock.mock_calls[14][2] == {"enabled": False} await hass.services.async_call( SWITCH_DOMAIN, @@ -896,8 +896,8 @@ async def test_switches( {"entity_id": "switch.block_media_streaming"}, blocking=True, ) - assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[14][2] == {"enabled": True} + assert aioclient_mock.call_count == 16 + assert aioclient_mock.mock_calls[15][2] == {"enabled": True} async def test_remove_switches( @@ -983,8 +983,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 12 - assert aioclient_mock.mock_calls[11][2] == { + assert aioclient_mock.call_count == 13 + assert aioclient_mock.mock_calls[12][2] == { "mac": "00:00:00:00:01:01", "cmd": "block-sta", } @@ -992,8 +992,8 @@ async def test_block_switches( await hass.services.async_call( SWITCH_DOMAIN, "turn_on", {"entity_id": "switch.block_client_1"}, blocking=True ) - assert aioclient_mock.call_count == 13 - assert aioclient_mock.mock_calls[12][2] == { + assert aioclient_mock.call_count == 14 + assert aioclient_mock.mock_calls[13][2] == { "mac": "00:00:00:00:01:01", "cmd": "unblock-sta", } From ec3ee7f02cfa8d9a2b0424f2c865c1196429698d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 24 Oct 2023 21:31:03 -0400 Subject: [PATCH 824/968] Update zwave_js/hard_reset_controller WS cmd (#102280) --- homeassistant/components/zwave_js/api.py | 21 ++++++++++++++++++++- tests/components/zwave_js/conftest.py | 14 +++++++++++--- tests/components/zwave_js/test_api.py | 18 ++++++++++++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 0e7c36c479d..a917aa44889 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -2463,5 +2463,24 @@ async def websocket_hard_reset_controller( driver: Driver, ) -> None: """Hard reset controller.""" + + @callback + def async_cleanup() -> None: + """Remove signal listeners.""" + for unsub in unsubs: + unsub() + unsubs.clear() + + @callback + def _handle_device_added(device: dr.DeviceEntry) -> None: + """Handle device is added.""" + if entry.entry_id in device.config_entries: + connection.send_result(msg[ID], device.id) + async_cleanup() + + msg[DATA_UNSUBSCRIBE] = unsubs = [ + async_dispatcher_connect( + hass, EVENT_DEVICE_ADDED_TO_REGISTRY, _handle_device_added + ) + ] await driver.async_hard_reset() - connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index db5495bce01..534f2fd2457 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -677,9 +677,19 @@ def central_scene_node_state_fixture(): # model fixtures +@pytest.fixture(name="listen_block") +def mock_listen_block_fixture(): + """Mock a listen block.""" + return asyncio.Event() + + @pytest.fixture(name="client") def mock_client_fixture( - controller_state, controller_node_state, version_state, log_config_state + controller_state, + controller_node_state, + version_state, + log_config_state, + listen_block, ): """Mock a client.""" with patch( @@ -693,9 +703,7 @@ def mock_client_fixture( async def listen(driver_ready: asyncio.Event) -> None: driver_ready.set() - listen_block = asyncio.Event() await listen_block.wait() - pytest.fail("Listen wasn't canceled!") async def disconnect(): client.connected = False diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 4ff7c481e37..9c4a6339a78 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -4653,12 +4653,21 @@ async def test_subscribe_node_statistics( async def test_hard_reset_controller( - hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator + hass: HomeAssistant, + client, + integration, + listen_block, + hass_ws_client: WebSocketGenerator, ) -> None: """Test that the hard_reset_controller WS API call works.""" entry = integration ws_client = await hass_ws_client(hass) + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, client.driver.controller.nodes[1])} + ) + client.async_send_command.return_value = {} await ws_client.send_json( { @@ -4667,8 +4676,13 @@ async def test_hard_reset_controller( ENTRY_ID: entry.entry_id, } ) + + listen_block.set() + listen_block.clear() + await hass.async_block_till_done() + msg = await ws_client.receive_json() - assert msg["result"] is None + assert msg["result"] == device.id assert msg["success"] assert len(client.async_send_command.call_args_list) == 1 From 7c93d4fccf75dd486355ab4728d568bec7af3ff8 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 25 Oct 2023 03:57:34 +0200 Subject: [PATCH 825/968] Bump zha-quirks to 0.0.106 (#102741) --- 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 dc8f06882e2..97f73cac97b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -24,7 +24,7 @@ "bellows==0.36.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.105", + "zha-quirks==0.0.106", "zigpy-deconz==0.21.1", "zigpy==0.58.1", "zigpy-xbee==0.18.3", diff --git a/requirements_all.txt b/requirements_all.txt index ac15d831ee1..a90a2475a89 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2795,7 +2795,7 @@ zeroconf==0.119.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zhong_hong zhong-hong-hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa9cc53aa9..18afccee3b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2089,7 +2089,7 @@ zeroconf==0.119.0 zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.105 +zha-quirks==0.0.106 # homeassistant.components.zha zigpy-deconz==0.21.1 From 8d5cb20285730d02ab8152d0808a7d22f02ff01e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Oct 2023 23:36:30 -0400 Subject: [PATCH 826/968] Bump ZHA radio dependencies (#102750) --- homeassistant/components/zha/manifest.json | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 97f73cac97b..6efee0e96ac 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,13 +21,13 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.5", + "bellows==0.36.8", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.106", "zigpy-deconz==0.21.1", - "zigpy==0.58.1", - "zigpy-xbee==0.18.3", + "zigpy==0.59.0", + "zigpy-xbee==0.19.0", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.6", "universal-silabs-flasher==0.0.14", diff --git a/requirements_all.txt b/requirements_all.txt index a90a2475a89..65d5ca29ca5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -521,7 +521,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 @@ -2807,7 +2807,7 @@ ziggo-mediabox-xl==1.1.0 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2816,7 +2816,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.58.1 +zigpy==0.59.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18afccee3b9..b89c7579d32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -445,7 +445,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.5 +bellows==0.36.8 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.2 @@ -2095,7 +2095,7 @@ zha-quirks==0.0.106 zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.3 +zigpy-xbee==0.19.0 # homeassistant.components.zha zigpy-zigate==0.11.0 @@ -2104,7 +2104,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.6 # homeassistant.components.zha -zigpy==0.58.1 +zigpy==0.59.0 # homeassistant.components.zwave_js zwave-js-server-python==0.52.1 From 6294339944ebf98dff9e3406d6088a5eb09cf5ca Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 24 Oct 2023 20:56:08 -0700 Subject: [PATCH 827/968] Improve ZHA King of Fans (#101859) --- homeassistant/components/zha/fan.py | 20 +++ tests/components/zha/test_fan.py | 180 ++++++++++++++++++++++- tests/components/zha/zha_devices_list.py | 2 +- 3 files changed, 199 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index b8cf2cd0339..05bf3469c7b 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -299,3 +299,23 @@ class IkeaFan(ZhaFan): return int( (100 / self.speed_count) * self.preset_name_to_mode[PRESET_MODE_AUTO] ) + + +@MULTI_MATCH( + cluster_handler_names=CLUSTER_HANDLER_FAN, + models={"HBUniversalCFRemote", "HDC52EastwindFan"}, +) +class KofFan(ZhaFan): + """Representation of a fan made by King Of Fans.""" + + _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE + + @property + def speed_range(self) -> tuple[int, int]: + """Return the range of speeds the fan supports. Off is not included.""" + return (1, 4) + + @property + def preset_modes_to_name(self) -> dict[int, str]: + """Return a dict from preset mode to name.""" + return {6: PRESET_MODE_SMART} diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 81ab1c2e0f5..737604482d8 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -76,7 +76,7 @@ def fan_platform_only(): @pytest.fixture def zigpy_device(zigpy_device_mock): - """Device tracker zigpy device.""" + """Fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [hvac.Fan.cluster_id], @@ -540,7 +540,7 @@ async def test_fan_update_entity( @pytest.fixture def zigpy_device_ikea(zigpy_device_mock): - """Device tracker zigpy device.""" + """Ikea fan zigpy device.""" endpoints = { 1: { SIG_EP_INPUT: [ @@ -725,3 +725,179 @@ async def test_fan_ikea_update_entity( assert cluster.read_attributes.await_count == 5 else: assert cluster.read_attributes.await_count == 8 + + +@pytest.fixture +def zigpy_device_kof(zigpy_device_mock): + """Fan by King of Fans zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="King Of Fans, Inc.", + model="HBUniversalCFRemote", + quirk=zhaquirks.kof.kof_mr101z.CeilingFan, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_kof( + hass: HomeAssistant, + zha_device_joined_restored: ZHADevice, + zigpy_device_kof: Device, +) -> None: + """Test ZHA fan platform for King of Fans.""" + zha_device = await zha_device_joined_restored(zigpy_device_kof) + cluster = zigpy_device_kof.endpoints.get(1).fan + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 2}, manufacturer=None) + ] + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 0}, manufacturer=None) + ] + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 4}, manufacturer=None) + ] + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_SMART) + assert cluster.write_attributes.mock_calls == [ + call({"fan_mode": 6}, manufacturer=None) + ] + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_kof, [cluster], (1,)) + + +@pytest.mark.parametrize( + ("plug_read", "expected_state", "expected_percentage", "expected_preset"), + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 25, None), + ({"fan_mode": 2}, STATE_ON, 50, None), + ({"fan_mode": 3}, STATE_ON, 75, None), + ({"fan_mode": 4}, STATE_ON, 100, None), + ({"fan_mode": 6}, STATE_ON, None, PRESET_MODE_SMART), + ), +) +async def test_fan_kof_init( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, + plug_read, + expected_state, + expected_percentage, + expected_preset, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == expected_state + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == expected_percentage + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == expected_preset + + +async def test_fan_kof_update_entity( + hass: HomeAssistant, + zha_device_joined_restored, + zigpy_device_kof, +) -> None: + """Test ZHA fan platform for King of Fans.""" + + cluster = zigpy_device_kof.endpoints.get(1).fan + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_kof) + entity_id = find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 2 + else: + assert cluster.read_attributes.await_count == 4 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 5 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 25 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 4 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 6 diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index 842110ace87..44f01555b19 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -1831,7 +1831,7 @@ DEVICES = [ }, ("fan", "00:11:22:33:44:55:66:77-1-514"): { DEV_SIG_CLUSTER_HANDLERS: ["fan"], - DEV_SIG_ENT_MAP_CLASS: "ZhaFan", + DEV_SIG_ENT_MAP_CLASS: "KofFan", DEV_SIG_ENT_MAP_ID: "fan.king_of_fans_inc_hbuniversalcfremote_fan", }, }, From 2e643c0c753abffc7c3f46ba46137c392f285710 Mon Sep 17 00:00:00 2001 From: AJ Jordan Date: Wed, 25 Oct 2023 00:07:38 -0400 Subject: [PATCH 828/968] Fix dead link in Kodi log message (#102743) --- homeassistant/components/kodi/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 0d62e6cfa10..f3459e891b7 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -64,7 +64,7 @@ async def async_get_service( _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://www.home-assistant.io/integrations/media_player.kodi/" + "https://www.home-assistant.io/integrations/kodi/" ) http_protocol = "https" if encryption else "http" From ece7ec6a3879f6e7329a325e0f8e0589fb839352 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 24 Oct 2023 23:08:41 -0500 Subject: [PATCH 829/968] Disable IPV6 in the august integration (#98003) --- homeassistant/components/august/__init__.py | 8 +++---- .../components/august/config_flow.py | 7 ++---- homeassistant/components/august/util.py | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/august/util.py diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 408d6e0be7e..c1eb21b6827 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -25,13 +25,14 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow +from homeassistant.helpers import device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway from .subscriber import AugustSubscriberMixin +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -46,10 +47,7 @@ YALEXS_BLE_DOMAIN = "yalexs_ble" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - session = aiohttp_client.async_create_clientsession(hass) + session = async_create_august_clientsession(hass) august_gateway = AugustGateway(hass, session) try: diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 670d1608421..0028db55415 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -13,7 +13,6 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -26,6 +25,7 @@ from .const import ( ) from .exceptions import CannotConnect, InvalidAuth, RequireValidation from .gateway import AugustGateway +from .util import async_create_august_clientsession _LOGGER = logging.getLogger(__name__) @@ -159,10 +159,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Set up the gateway.""" if self._august_gateway is not None: return self._august_gateway - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._aiohttp_session = async_create_august_clientsession(self.hass) self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) return self._august_gateway diff --git a/homeassistant/components/august/util.py b/homeassistant/components/august/util.py new file mode 100644 index 00000000000..9703fdc6fcd --- /dev/null +++ b/homeassistant/components/august/util.py @@ -0,0 +1,24 @@ +"""August util functions.""" + +import socket + +import aiohttp + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client + + +@callback +def async_create_august_clientsession(hass: HomeAssistant) -> aiohttp.ClientSession: + """Create an aiohttp session for the august integration.""" + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + # + # The family is set to AF_INET because IPv6 keeps coming up as an issue + # see https://github.com/home-assistant/core/issues/97146 + # + # When https://github.com/aio-libs/aiohttp/issues/4451 is implemented + # we can allow IPv6 again + # + return aiohttp_client.async_create_clientsession(hass, family=socket.AF_INET) From dd111416e746c6d3c8f1ec13fa5e1023660de919 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 25 Oct 2023 00:10:31 -0400 Subject: [PATCH 830/968] Add cleaning binary sensor to Roborock (#102748) --- homeassistant/components/roborock/binary_sensor.py | 8 ++++++++ homeassistant/components/roborock/strings.json | 3 +++ tests/components/roborock/test_binary_sensor.py | 3 ++- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index 320b0fc7c6d..a8f6a6fbb4f 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -69,6 +69,14 @@ BINARY_SENSOR_DESCRIPTIONS = [ entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_shortage_status, ), + RoborockBinarySensorDescription( + key="in_cleaning", + translation_key="in_cleaning", + icon="mdi:vacuum", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.in_cleaning, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 87d06f92f46..06cffcc2291 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -28,6 +28,9 @@ }, "entity": { "binary_sensor": { + "in_cleaning": { + "name": "Cleaning" + }, "mop_attached": { "name": "Mop attached" }, diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 4edf8ff4710..e70dac5ffc9 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,7 +9,7 @@ async def test_binary_sensors( hass: HomeAssistant, setup_entry: MockConfigEntry ) -> None: """Test binary sensors and check test values are correctly set.""" - assert len(hass.states.async_all("binary_sensor")) == 6 + assert len(hass.states.async_all("binary_sensor")) == 8 assert hass.states.get("binary_sensor.roborock_s7_maxv_mop_attached").state == "on" assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_box_attached").state @@ -18,3 +18,4 @@ async def test_binary_sensors( assert ( hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" ) + assert hass.states.get("binary_sensor.roborock_s7_maxv_cleaning").state == "off" From 9047dcf242592ac0a5f79dfdd24f01793363a5ab Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 06:11:06 +0200 Subject: [PATCH 831/968] Use real devices in text device action tests (#102728) --- tests/components/text/test_device_action.py | 34 +++++++++++++++++---- 1 file changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index 88bf692b711..59b77ecfa06 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -137,9 +137,21 @@ async def test_get_action_no_state( assert actions == unordered(expected_actions) -async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) -> None: +async def test_action( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -154,7 +166,7 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.id, "type": "set_value", "value": 0.3, @@ -175,10 +187,20 @@ async def test_action(hass: HomeAssistant, entity_registry: er.EntityRegistry) - async def test_action_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test for actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, 0.5, {"min_value": 0.0, "max_value": 1.0}) assert await async_setup_component( @@ -193,7 +215,7 @@ async def test_action_legacy( }, "action": { "domain": DOMAIN, - "device_id": "abcdefgh", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "set_value", "value": 0.3, From b870933dc755f6c2d0f80852d61400eeba00a25e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 06:11:55 +0200 Subject: [PATCH 832/968] Use real devices in remote device action tests (#102725) --- tests/components/remote/test_device_action.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 97ff2fd58a0..76e6075a18f 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -110,12 +110,21 @@ async def test_get_actions_hidden_auxiliary( async def test_action( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -126,7 +135,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_off", }, @@ -135,7 +144,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turn_on", }, @@ -144,7 +153,7 @@ async def test_action( "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "toggle", }, @@ -176,12 +185,21 @@ async def test_action( async def test_action_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off actions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) assert await async_setup_component( hass, @@ -192,7 +210,7 @@ async def test_action_legacy( "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turn_off", }, From 626123acc0efc1a851f137125fb2718322cf8764 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 06:13:00 +0200 Subject: [PATCH 833/968] Use real devices in select device trigger tests (#102694) --- .../components/select/test_device_trigger.py | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/tests/components/select/test_device_trigger.py b/tests/components/select/test_device_trigger.py index 8a6ccd43abe..0be5c605dc1 100644 --- a/tests/components/select/test_device_trigger.py +++ b/tests/components/select/test_device_trigger.py @@ -113,10 +113,21 @@ async def test_get_triggers_hidden_auxiliary( async def test_if_fires_on_state_change( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -131,7 +142,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "to": "option2", @@ -152,7 +163,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option2", @@ -173,7 +184,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "current_option_changed", "from": "option3", @@ -224,10 +235,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set( entry.entity_id, "option1", {"options": ["option1", "option2", "option3"]} @@ -242,7 +264,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "current_option_changed", "to": "option2", From aa362295193f6f36d6db65a9312514452a7e0e5f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Oct 2023 00:13:10 -0400 Subject: [PATCH 834/968] Remove eight_sleep integration (#102669) --- .coveragerc | 3 - CODEOWNERS | 2 - .../components/eight_sleep/__init__.py | 229 ++----------- .../components/eight_sleep/binary_sensor.py | 65 ---- .../components/eight_sleep/config_flow.py | 87 +---- homeassistant/components/eight_sleep/const.py | 16 - .../components/eight_sleep/manifest.json | 7 +- .../components/eight_sleep/sensor.py | 301 ------------------ .../components/eight_sleep/services.yaml | 20 -- .../components/eight_sleep/strings.json | 35 +- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/integrations.json | 6 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/eight_sleep/conftest.py | 29 -- .../eight_sleep/test_config_flow.py | 82 ----- tests/components/eight_sleep/test_init.py | 50 +++ 17 files changed, 83 insertions(+), 856 deletions(-) delete mode 100644 homeassistant/components/eight_sleep/binary_sensor.py delete mode 100644 homeassistant/components/eight_sleep/const.py delete mode 100644 homeassistant/components/eight_sleep/sensor.py delete mode 100644 homeassistant/components/eight_sleep/services.yaml delete mode 100644 tests/components/eight_sleep/conftest.py delete mode 100644 tests/components/eight_sleep/test_config_flow.py create mode 100644 tests/components/eight_sleep/test_init.py diff --git a/.coveragerc b/.coveragerc index 9634ca2edb8..eaae941c70d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -286,9 +286,6 @@ omit = homeassistant/components/edl21/__init__.py homeassistant/components/edl21/sensor.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/__init__.py - homeassistant/components/eight_sleep/binary_sensor.py - homeassistant/components/eight_sleep/sensor.py homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py diff --git a/CODEOWNERS b/CODEOWNERS index 5441fea97d1..62dccee04c7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -319,8 +319,6 @@ build.json @home-assistant/supervisor /homeassistant/components/efergy/ @tkdrob /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt -/homeassistant/components/eight_sleep/ @mezz64 @raman325 -/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index b8066f2eb31..ab5eff3b60f 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,222 +1,37 @@ -"""Support for Eight smart mattress covers and mattresses.""" +"""The Eight Sleep integration.""" from __future__ import annotations -from dataclasses import dataclass -from datetime import timedelta -import logging - -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -from pyeight.user import EightUser -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_HW_VERSION, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_SW_VERSION, - CONF_PASSWORD, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceInfo, async_get -from homeassistant.helpers.typing import UNDEFINED, ConfigType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) +from homeassistant.helpers import issue_registry as ir -from .const import DOMAIN, NAME_MAP - -_LOGGER = logging.getLogger(__name__) - -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] - -HEAT_SCAN_INTERVAL = timedelta(seconds=60) -USER_SCAN_INTERVAL = timedelta(seconds=300) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ), - }, - extra=vol.ALLOW_EXTRA, -) +DOMAIN = "eight_sleep" -@dataclass -class EightSleepConfigEntryData: - """Data used for all entities for a given config entry.""" - - api: EightSleep - heat_coordinator: DataUpdateCoordinator - user_coordinator: DataUpdateCoordinator - - -def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: - """Get the device's unique ID.""" - unique_id = eight.device_id - assert unique_id - if user_obj: - unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}" - return unique_id - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Old set up method for the Eight Sleep component.""" - if DOMAIN in config: - _LOGGER.warning( - "Your Eight Sleep configuration has been imported into the UI; " - "please remove it from configuration.yaml as support for it " - "will be removed in a future release" - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up the Eight Sleep config entry.""" - eight = EightSleep( - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - hass.config.time_zone, - client_session=async_get_clientsession(hass), - ) - - # Authenticate, build sensors - try: - success = await eight.start() - except RequestError as err: - raise ConfigEntryNotReady from err - if not success: - # Authentication failed, cannot continue - return False - - heat_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( +async def async_setup_entry(hass: HomeAssistant, _: ConfigEntry) -> bool: + """Set up Eight Sleep from a config entry.""" + ir.async_create_issue( hass, - _LOGGER, - name=f"{DOMAIN}_heat", - update_interval=HEAT_SCAN_INTERVAL, - update_method=eight.update_device_data, + DOMAIN, + DOMAIN, + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="integration_removed", + translation_placeholders={ + "entries": "/config/integrations/integration/eight_sleep" + }, ) - user_coordinator: DataUpdateCoordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{DOMAIN}_user", - update_interval=USER_SCAN_INTERVAL, - update_method=eight.update_user_data, - ) - await heat_coordinator.async_config_entry_first_refresh() - await user_coordinator.async_config_entry_first_refresh() - - if not eight.users: - # No users, cannot continue - return False - - dev_reg = async_get(hass) - assert eight.device_data - device_data = { - ATTR_MANUFACTURER: "Eight Sleep", - ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), - ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( - "hwRevision", UNDEFINED - ), - ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), - } - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight))}, - name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", - **device_data, - ) - for user in eight.users.values(): - assert user.user_profile - dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, - name=f"{user.user_profile['firstName']}'s Eight Sleep Side", - via_device=(DOMAIN, _get_device_unique_id(eight)), - **device_data, - ) - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( - eight, heat_coordinator, user_coordinator - ) - - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - # stop the API before unloading everything - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - await config_entry_data.api.stop() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + if all( + config_entry.state is ConfigEntryState.NOT_LOADED + for config_entry in hass.config_entries.async_entries(DOMAIN) + if config_entry.entry_id != entry.entry_id + ): + ir.async_delete_issue(hass, DOMAIN, DOMAIN) - return unload_ok - - -class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): - """The base Eight Sleep entity class.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the data object.""" - super().__init__(coordinator) - self._config_entry = entry - self._eight = eight - self._user_id = user_id - self._sensor = sensor - self._user_obj: EightUser | None = None - if user_id: - self._user_obj = self._eight.users[user_id] - - mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) - if self._user_obj is not None: - assert self._user_obj.user_profile - name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" - self._attr_name = name - else: - self._attr_name = f"Eight Sleep {mapped_name}" - unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - self._attr_unique_id = unique_id - identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} - self._attr_device_info = DeviceInfo(identifiers=identifiers) - - async def async_heat_set(self, target: int, duration: int) -> None: - """Handle eight sleep service calls.""" - if self._user_obj is None: - raise HomeAssistantError( - "This entity does not support the heat set service." - ) - - await self._user_obj.set_heating_level(target, duration) - config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ - self._config_entry.entry_id - ] - await config_entry_data.heat_coordinator.async_request_refresh() + return True diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py deleted file mode 100644 index 7ad1b882008..00000000000 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Support for Eight Sleep binary sensors.""" -from __future__ import annotations - -import logging - -from pyeight.eight import EightSleep - -from homeassistant.components.binary_sensor import ( - BinarySensorDeviceClass, - BinarySensorEntity, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) -BINARY_SENSORS = ["bed_presence"] - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep binary sensor.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - async_add_entities( - EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) - for user in eight.users.values() - for binary_sensor in BINARY_SENSORS - ) - - -class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): - """Representation of a Eight Sleep heat-based sensor.""" - - _attr_device_class = BinarySensorDeviceClass.OCCUPANCY - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str | None, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - _LOGGER.debug( - "Presence Sensor: %s, Side: %s, User: %s", - sensor, - self._user_obj.side, - user_id, - ) - - @property - def is_on(self) -> bool: - """Return true if the binary sensor is on.""" - assert self._user_obj - return bool(self._user_obj.bed_presence) diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py index 504fbeb2817..8839cdf4719 100644 --- a/homeassistant/components/eight_sleep/config_flow.py +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -1,90 +1,11 @@ -"""Config flow for Eight Sleep integration.""" -from __future__ import annotations +"""The Eight Sleep integration config flow.""" -import logging -from typing import Any +from homeassistant.config_entries import ConfigFlow -from pyeight.eight import EightSleep -from pyeight.exceptions import RequestError -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.selector import ( - TextSelector, - TextSelectorConfig, - TextSelectorType, -) - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_USERNAME): TextSelector( - TextSelectorConfig(type=TextSelectorType.EMAIL) - ), - vol.Required(CONF_PASSWORD): TextSelector( - TextSelectorConfig(type=TextSelectorType.PASSWORD) - ), - } -) +from . import DOMAIN -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class EightSleepConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Eight Sleep.""" VERSION = 1 - - async def _validate_data(self, config: dict[str, str]) -> str | None: - """Validate input data and return any error.""" - await self.async_set_unique_id(config[CONF_USERNAME].lower()) - self._abort_if_unique_id_configured() - - eight = EightSleep( - config[CONF_USERNAME], - config[CONF_PASSWORD], - self.hass.config.time_zone, - client_session=async_get_clientsession(self.hass), - ) - - try: - await eight.fetch_token() - except RequestError as err: - return str(err) - - return None - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) - - if (err := await self._validate_data(user_input)) is not None: - return self.async_show_form( - step_id="user", - data_schema=STEP_USER_DATA_SCHEMA, - errors={"base": "cannot_connect"}, - description_placeholders={"error": err}, - ) - - return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) - - async def async_step_import(self, import_config: dict) -> FlowResult: - """Handle import.""" - if (err := await self._validate_data(import_config)) is not None: - _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) - return self.async_abort( - reason="cannot_connect", description_placeholders={"error": err} - ) - - return self.async_create_entry( - title=import_config[CONF_USERNAME], data=import_config - ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py deleted file mode 100644 index 23689066665..00000000000 --- a/homeassistant/components/eight_sleep/const.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Eight Sleep constants.""" -DOMAIN = "eight_sleep" - -HEAT_ENTITY = "heat" -USER_ENTITY = "user" - -NAME_MAP = { - "current_sleep": "Sleep Session", - "current_sleep_fitness": "Sleep Fitness", - "last_sleep": "Previous Sleep Session", -} - -SERVICE_HEAT_SET = "heat_set" - -ATTR_TARGET = "target" -ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 71e01f75d46..a4f7482c920 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -1,10 +1,9 @@ { "domain": "eight_sleep", "name": "Eight Sleep", - "codeowners": ["@mezz64", "@raman325"], - "config_flow": true, + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/eight_sleep", + "integration_type": "system", "iot_class": "cloud_polling", - "loggers": ["pyeight"], - "requirements": ["pyEight==0.3.2"] + "requirements": [] } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py deleted file mode 100644 index e546318a4dd..00000000000 --- a/homeassistant/components/eight_sleep/sensor.py +++ /dev/null @@ -1,301 +0,0 @@ -"""Support for Eight Sleep sensors.""" -from __future__ import annotations - -import logging -from typing import Any - -from pyeight.eight import EightSleep -import voluptuous as vol - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorStateClass, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE, UnitOfTemperature -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - -from . import EightSleepBaseEntity, EightSleepConfigEntryData -from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET - -ATTR_ROOM_TEMP = "Room Temperature" -ATTR_AVG_ROOM_TEMP = "Average Room Temperature" -ATTR_BED_TEMP = "Bed Temperature" -ATTR_AVG_BED_TEMP = "Average Bed Temperature" -ATTR_RESP_RATE = "Respiratory Rate" -ATTR_AVG_RESP_RATE = "Average Respiratory Rate" -ATTR_HEART_RATE = "Heart Rate" -ATTR_AVG_HEART_RATE = "Average Heart Rate" -ATTR_SLEEP_DUR = "Time Slept" -ATTR_LIGHT_PERC = f"Light Sleep {PERCENTAGE}" -ATTR_DEEP_PERC = f"Deep Sleep {PERCENTAGE}" -ATTR_REM_PERC = f"REM Sleep {PERCENTAGE}" -ATTR_TNT = "Tosses & Turns" -ATTR_SLEEP_STAGE = "Sleep Stage" -ATTR_TARGET_HEAT = "Target Heating Level" -ATTR_ACTIVE_HEAT = "Heating Active" -ATTR_DURATION_HEAT = "Heating Time Remaining" -ATTR_PROCESSING = "Processing" -ATTR_SESSION_START = "Session Start" -ATTR_FIT_DATE = "Fitness Date" -ATTR_FIT_DURATION_SCORE = "Fitness Duration Score" -ATTR_FIT_ASLEEP_SCORE = "Fitness Asleep Score" -ATTR_FIT_OUT_SCORE = "Fitness Out-of-Bed Score" -ATTR_FIT_WAKEUP_SCORE = "Fitness Wakeup Score" - -_LOGGER = logging.getLogger(__name__) - -EIGHT_USER_SENSORS = [ - "current_sleep", - "current_sleep_fitness", - "last_sleep", - "bed_temperature", - "sleep_stage", -] -EIGHT_HEAT_SENSORS = ["bed_state"] -EIGHT_ROOM_SENSORS = ["room_temperature"] - -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = { - ATTR_TARGET: VALID_TARGET_HEAT, - ATTR_DURATION: VALID_DURATION, -} - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the eight sleep sensors.""" - config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] - eight = config_entry_data.api - heat_coordinator = config_entry_data.heat_coordinator - user_coordinator = config_entry_data.user_coordinator - - all_sensors: list[SensorEntity] = [] - - for obj in eight.users.values(): - all_sensors.extend( - EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_USER_SENSORS - ) - all_sensors.extend( - EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) - for sensor in EIGHT_HEAT_SENSORS - ) - - all_sensors.extend( - EightRoomSensor(entry, user_coordinator, eight, sensor) - for sensor in EIGHT_ROOM_SENSORS - ) - - async_add_entities(all_sensors) - - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_HEAT_SET, - SERVICE_EIGHT_SCHEMA, - "async_heat_set", - ) - - -class EightHeatSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep heat-based sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - _LOGGER.debug( - "Heat Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> int | None: - """Return the state of the sensor.""" - assert self._user_obj - return self._user_obj.heating_level - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Return device state attributes.""" - assert self._user_obj - return { - ATTR_TARGET_HEAT: self._user_obj.target_heating_level, - ATTR_ACTIVE_HEAT: self._user_obj.now_heating, - ATTR_DURATION_HEAT: self._user_obj.heating_remaining, - } - - -def _get_breakdown_percent( - attr: dict[str, Any], key: str, denominator: int | float -) -> int | float: - """Get a breakdown percent.""" - try: - return round((attr["breakdown"][key] / denominator) * 100, 2) - except (ZeroDivisionError, KeyError): - return 0 - - -def _get_rounded_value(attr: dict[str, Any], key: str) -> int | float | None: - """Get rounded value for given key.""" - if (val := attr.get(key)) is None: - return None - return round(val, 2) - - -class EightUserSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep user-based sensor.""" - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - user_id: str, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, user_id, sensor) - assert self._user_obj - - if self._sensor == "bed_temperature": - self._attr_icon = "mdi:thermometer" - self._attr_device_class = SensorDeviceClass.TEMPERATURE - self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - elif self._sensor in ("current_sleep", "last_sleep", "current_sleep_fitness"): - self._attr_native_unit_of_measurement = "Score" - - if self._sensor != "sleep_stage": - self._attr_state_class = SensorStateClass.MEASUREMENT - - _LOGGER.debug( - "User Sensor: %s, Side: %s, User: %s", - self._sensor, - self._user_obj.side, - self._user_id, - ) - - @property - def native_value(self) -> str | int | float | None: - """Return the state of the sensor.""" - if not self._user_obj: - return None - - if "current" in self._sensor: - if "fitness" in self._sensor: - return self._user_obj.current_sleep_fitness_score - return self._user_obj.current_sleep_score - - if "last" in self._sensor: - return self._user_obj.last_sleep_score - - if self._sensor == "bed_temperature": - return self._user_obj.current_values["bed_temp"] - - if self._sensor == "sleep_stage": - return self._user_obj.current_values["stage"] - - return None - - @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return device state attributes.""" - attr = None - if "current" in self._sensor and self._user_obj: - if "fitness" in self._sensor: - attr = self._user_obj.current_fitness_values - else: - attr = self._user_obj.current_values - elif "last" in self._sensor and self._user_obj: - attr = self._user_obj.last_values - - if attr is None: - # Skip attributes if sensor type doesn't support - return None - - if "fitness" in self._sensor: - state_attr = { - ATTR_FIT_DATE: attr["date"], - ATTR_FIT_DURATION_SCORE: attr["duration"], - ATTR_FIT_ASLEEP_SCORE: attr["asleep"], - ATTR_FIT_OUT_SCORE: attr["out"], - ATTR_FIT_WAKEUP_SCORE: attr["wakeup"], - } - return state_attr - - state_attr = {ATTR_SESSION_START: attr["date"]} - state_attr[ATTR_TNT] = attr["tnt"] - state_attr[ATTR_PROCESSING] = attr["processing"] - - if attr.get("breakdown") is not None: - sleep_time = sum(attr["breakdown"].values()) - attr["breakdown"]["awake"] - state_attr[ATTR_SLEEP_DUR] = sleep_time - state_attr[ATTR_LIGHT_PERC] = _get_breakdown_percent( - attr, "light", sleep_time - ) - state_attr[ATTR_DEEP_PERC] = _get_breakdown_percent( - attr, "deep", sleep_time - ) - state_attr[ATTR_REM_PERC] = _get_breakdown_percent(attr, "rem", sleep_time) - - room_temp = _get_rounded_value(attr, "room_temp") - bed_temp = _get_rounded_value(attr, "bed_temp") - - if "current" in self._sensor: - state_attr[ATTR_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_SLEEP_STAGE] = attr["stage"] - state_attr[ATTR_ROOM_TEMP] = room_temp - state_attr[ATTR_BED_TEMP] = bed_temp - elif "last" in self._sensor: - state_attr[ATTR_AVG_RESP_RATE] = _get_rounded_value(attr, "resp_rate") - state_attr[ATTR_AVG_HEART_RATE] = _get_rounded_value(attr, "heart_rate") - state_attr[ATTR_AVG_ROOM_TEMP] = room_temp - state_attr[ATTR_AVG_BED_TEMP] = bed_temp - - return state_attr - - -class EightRoomSensor(EightSleepBaseEntity, SensorEntity): - """Representation of an eight sleep room sensor.""" - - _attr_icon = "mdi:thermometer" - _attr_device_class = SensorDeviceClass.TEMPERATURE - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS - - def __init__( - self, - entry, - coordinator: DataUpdateCoordinator, - eight: EightSleep, - sensor: str, - ) -> None: - """Initialize the sensor.""" - super().__init__(entry, coordinator, eight, None, sensor) - - @property - def native_value(self) -> int | float | None: - """Return the state of the sensor.""" - return self._eight.room_temperature diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml deleted file mode 100644 index b191187bb0a..00000000000 --- a/homeassistant/components/eight_sleep/services.yaml +++ /dev/null @@ -1,20 +0,0 @@ -heat_set: - target: - entity: - integration: eight_sleep - domain: sensor - fields: - duration: - required: true - selector: - number: - min: 0 - max: 28800 - unit_of_measurement: seconds - target: - required: true - selector: - number: - min: -100 - max: 100 - unit_of_measurement: "°" diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json index b2fb73cc020..15773084462 100644 --- a/homeassistant/components/eight_sleep/strings.json +++ b/homeassistant/components/eight_sleep/strings.json @@ -1,35 +1,8 @@ { - "config": { - "step": { - "user": { - "data": { - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", - "cannot_connect": "[%key:component::eight_sleep::config::error::cannot_connect%]" - } - }, - "services": { - "heat_set": { - "name": "Heat set", - "description": "Sets heating/cooling level for eight sleep.", - "fields": { - "duration": { - "name": "Duration", - "description": "Duration to heat/cool at the target level in seconds." - }, - "target": { - "name": "Target", - "description": "Target cooling/heating level from -100 to 100." - } - } + "issues": { + "integration_removed": { + "title": "The Eight Sleep integration has been removed", + "description": "The Eight Sleep integration has been removed from Home Assistant.\n\nThe Eight Sleep API has changed and now requires a unique secret which is inaccessible outside of their apps.\n\nTo resolve this issue, please remove the (now defunct) integration entries from your Home Assistant setup. [Click here to see your existing Eight Sleep integration entries]({entries})." } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 64806d8fb86..88706ed4c94 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -121,7 +121,6 @@ FLOWS = { "ecowitt", "edl21", "efergy", - "eight_sleep", "electrasmart", "electric_kiwi", "elgato", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index b89139d7447..36ef89216e2 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1375,12 +1375,6 @@ "config_flow": false, "iot_class": "local_polling" }, - "eight_sleep": { - "name": "Eight Sleep", - "integration_type": "hub", - "config_flow": true, - "iot_class": "cloud_polling" - }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 65d5ca29ca5..a4c682fc5c8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1551,9 +1551,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b89c7579d32..30e153543c6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1184,9 +1184,6 @@ pyControl4==1.1.0 # homeassistant.components.duotecno pyDuotecno==2023.10.1 -# homeassistant.components.eight_sleep -pyEight==0.3.2 - # homeassistant.components.electrasmart pyElectra==1.2.0 diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py deleted file mode 100644 index 753fe1e30d5..00000000000 --- a/tests/components/eight_sleep/conftest.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Fixtures for Eight Sleep.""" -from unittest.mock import patch - -from pyeight.exceptions import RequestError -import pytest - - -@pytest.fixture(name="bypass", autouse=True) -def bypass_fixture(): - """Bypasses things that slow te tests down or block them from testing the behavior.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - ), patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", - ), patch( - "homeassistant.components.eight_sleep.async_setup_entry", - return_value=True, - ): - yield - - -@pytest.fixture(name="token_error") -def token_error_fixture(): - """Simulate error when fetching token.""" - with patch( - "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", - side_effect=RequestError, - ): - yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py deleted file mode 100644 index 6a64f6a5731..00000000000 --- a/tests/components/eight_sleep/test_config_flow.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Test the Eight Sleep config flow.""" -from homeassistant import config_entries -from homeassistant.components.eight_sleep.const import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "test-username", - "password": "test-password", - }, - ) - - assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "test-username" - assert result2["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_form_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "username": "bad-username", - "password": "bad-password", - }, - ) - - assert result2["type"] == FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_import(hass: HomeAssistant) -> None: - """Test import works.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "test-username", - "password": "test-password", - }, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "test-username" - assert result["data"] == { - "username": "test-username", - "password": "test-password", - } - - -async def test_import_invalid_auth(hass: HomeAssistant, token_error) -> None: - """Test we handle invalid auth on import.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": "bad-username", - "password": "bad-password", - }, - ) - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" diff --git a/tests/components/eight_sleep/test_init.py b/tests/components/eight_sleep/test_init.py new file mode 100644 index 00000000000..6b94ff31139 --- /dev/null +++ b/tests/components/eight_sleep/test_init.py @@ -0,0 +1,50 @@ +"""Tests for the Eight Sleep integration.""" + +from homeassistant.components.eight_sleep import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir + +from tests.common import MockConfigEntry + + +async def test_mazda_repair_issue( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: + """Test the Eight Sleep configuration entry loading/unloading handles the repair.""" + config_entry_1 = MockConfigEntry( + title="Example 1", + domain=DOMAIN, + ) + config_entry_1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_1.entry_id) + await hass.async_block_till_done() + assert config_entry_1.state is ConfigEntryState.LOADED + + # Add a second one + config_entry_2 = MockConfigEntry( + title="Example 2", + domain=DOMAIN, + ) + config_entry_2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the first one + await hass.config_entries.async_remove(config_entry_1.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) + + # Remove the second one + await hass.config_entries.async_remove(config_entry_2.entry_id) + await hass.async_block_till_done() + + assert config_entry_1.state is ConfigEntryState.NOT_LOADED + assert config_entry_2.state is ConfigEntryState.NOT_LOADED + assert issue_registry.async_get_issue(DOMAIN, DOMAIN) is None From 789a00043a6e35666a93bd6616be114eb9f35516 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 06:14:53 +0200 Subject: [PATCH 835/968] Use real devices in device automation tests (#102736) Co-authored-by: J. Nick Koston --- .../components/device_automation/test_init.py | 147 ++++++++++++++---- 1 file changed, 114 insertions(+), 33 deletions(-) diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 74150af67ae..3a7105684f4 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.device_automation import ( toggle_entity, ) from homeassistant.components.websocket_api.const import TYPE_RESULT -from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType @@ -908,7 +908,11 @@ async def test_automation_with_non_existing_integration( async def test_automation_with_device_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device action.""" @@ -916,6 +920,16 @@ async def test_automation_with_device_action( module = module_cache["fake_integration.device_action"] module.async_call_action_from_config = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -924,9 +938,9 @@ async def test_automation_with_device_action( "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, "action": { - "device_id": "", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turn_on", }, } @@ -999,7 +1013,11 @@ async def test_automation_with_integration_without_device_action( async def test_automation_with_device_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device condition.""" @@ -1007,6 +1025,16 @@ async def test_automation_with_device_condition( module = module_cache["fake_integration.device_condition"] module.async_condition_from_config = Mock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1016,9 +1044,9 @@ async def test_automation_with_device_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "is_on", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1098,7 +1126,11 @@ async def test_automation_with_integration_without_device_condition( async def test_automation_with_device_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, fake_integration + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + fake_integration, ) -> None: """Test automation with a device trigger.""" @@ -1106,6 +1138,16 @@ async def test_automation_with_device_trigger( module = module_cache["fake_integration.device_trigger"] module.async_attach_trigger = AsyncMock() + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry = entity_registry.async_get_or_create( + "fake_integration", "test", "5678", device_id=device_entry.id + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1114,9 +1156,9 @@ async def test_automation_with_device_trigger( "alias": "hello", "trigger": { "platform": "device", - "device_id": "none", + "device_id": device_entry.id, "domain": "fake_integration", - "entity_id": "blah.blah", + "entity_id": entity_entry.id, "type": "turned_off", }, "action": {"service": "test.automation", "entity_id": "hello.world"}, @@ -1199,9 +1241,20 @@ async def test_automation_with_integration_without_device_trigger( async def test_automation_with_bad_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test automation with bad device action.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1209,7 +1262,7 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": "", "domain": "light"}, + "action": {"device_id": device_entry.id, "domain": "light"}, } }, ) @@ -1218,9 +1271,20 @@ async def test_automation_with_bad_action( async def test_automation_with_bad_condition_action( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test automation with bad device action.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1228,7 +1292,11 @@ async def test_automation_with_bad_condition_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"condition": "device", "device_id": "", "domain": "light"}, + "action": { + "condition": "device", + "device_id": device_entry.id, + "domain": "light", + }, } }, ) @@ -1283,16 +1351,29 @@ def calls(hass): async def test_automation_with_sub_condition( - hass: HomeAssistant, calls, enable_custom_integrations: None + hass: HomeAssistant, + calls, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, ) -> None: """Test automation with device condition under and/or conditions.""" DOMAIN = "light" - platform = getattr(hass.components, f"test.{DOMAIN}") - platform.init() - assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - await hass.async_block_till_done() - ent1, ent2, ent3 = platform.ENTITIES + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_entry1 = entity_registry.async_get_or_create( + "fake_integration", "test", "0001", device_id=device_entry.id + ) + entity_entry2 = entity_registry.async_get_or_create( + "fake_integration", "test", "0002", device_id=device_entry.id + ) + + hass.states.async_set(entity_entry1.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_OFF) assert await async_setup_component( hass, @@ -1308,15 +1389,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1339,15 +1420,15 @@ async def test_automation_with_sub_condition( { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent1.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry1.id, "type": "is_on", }, { "condition": "device", "domain": DOMAIN, - "device_id": "", - "entity_id": ent2.entity_id, + "device_id": device_entry.id, + "entity_id": entity_entry2.id, "type": "is_on", }, ], @@ -1365,8 +1446,8 @@ async def test_automation_with_sub_condition( }, ) await hass.async_block_till_done() - assert hass.states.get(ent1.entity_id).state == STATE_ON - assert hass.states.get(ent2.entity_id).state == STATE_OFF + assert hass.states.get(entity_entry1.entity_id).state == STATE_ON + assert hass.states.get(entity_entry2.entity_id).state == STATE_OFF assert len(calls) == 0 hass.bus.async_fire("test_event1") @@ -1374,18 +1455,18 @@ async def test_automation_with_sub_condition( assert len(calls) == 1 assert calls[0].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.states.async_set(entity_entry1.entity_id, STATE_OFF) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 - hass.states.async_set(ent2.entity_id, STATE_ON) + hass.states.async_set(entity_entry2.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "or event - test_event1" - hass.states.async_set(ent1.entity_id, STATE_ON) + hass.states.async_set(entity_entry1.entity_id, STATE_ON) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 4 From ad692f3341855a5191a994b73301e119dedccd85 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Wed, 25 Oct 2023 17:14:58 +1300 Subject: [PATCH 836/968] ESPHome Text entities (#102742) --- .../components/esphome/entry_data.py | 2 + .../components/esphome/manifest.json | 2 +- homeassistant/components/esphome/text.py | 63 ++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/esphome/test_text.py | 115 ++++++++++++++++++ 6 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/esphome/text.py create mode 100644 tests/components/esphome/test_text.py diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 21a8141647d..e53200c2e90 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -29,6 +29,7 @@ from aioesphomeapi import ( SensorInfo, SensorState, SwitchInfo, + TextInfo, TextSensorInfo, UserService, build_unique_id, @@ -68,6 +69,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = { SelectInfo: Platform.SELECT, SensorInfo: Platform.SENSOR, SwitchInfo: Platform.SWITCH, + TextInfo: Platform.TEXT, TextSensorInfo: Platform.SENSOR, } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5e6d56b6ca2..702f75b166e 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -16,7 +16,7 @@ "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ "async-interrupt==1.1.1", - "aioesphomeapi==18.0.12", + "aioesphomeapi==18.1.0", "bluetooth-data-tools==1.13.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/text.py b/homeassistant/components/esphome/text.py new file mode 100644 index 00000000000..49049eecfd4 --- /dev/null +++ b/homeassistant/components/esphome/text.py @@ -0,0 +1,63 @@ +"""Support for esphome texts.""" +from __future__ import annotations + +from aioesphomeapi import EntityInfo, TextInfo, TextMode as EsphomeTextMode, TextState + +from homeassistant.components.text import TextEntity, TextMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import EsphomeEntity, esphome_state_property, platform_async_setup_entry +from .enum_mapper import EsphomeEnumMapper + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up esphome texts based on a config entry.""" + await platform_async_setup_entry( + hass, + entry, + async_add_entities, + info_type=TextInfo, + entity_type=EsphomeText, + state_type=TextState, + ) + + +TEXT_MODES: EsphomeEnumMapper[EsphomeTextMode, TextMode] = EsphomeEnumMapper( + { + EsphomeTextMode.TEXT: TextMode.TEXT, + EsphomeTextMode.PASSWORD: TextMode.PASSWORD, + } +) + + +class EsphomeText(EsphomeEntity[TextInfo, TextState], TextEntity): + """A text implementation for esphome.""" + + @callback + def _on_static_info_update(self, static_info: EntityInfo) -> None: + """Set attrs from static info.""" + super()._on_static_info_update(static_info) + static_info = self._static_info + self._attr_native_min = static_info.min_length + self._attr_native_max = static_info.max_length + self._attr_pattern = static_info.pattern + self._attr_mode = TEXT_MODES.from_esphome(static_info.mode) or TextMode.TEXT + + @property + @esphome_state_property + def native_value(self) -> str | None: + """Return the state of the entity.""" + state = self._state + if state.missing_state: + return None + return state.state + + async def async_set_value(self, value: str) -> None: + """Update the current value.""" + await self._client.text_command(self._key, value) diff --git a/requirements_all.txt b/requirements_all.txt index a4c682fc5c8..a4c4edaef92 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,7 +237,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.12 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30e153543c6..765112d3dc5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==18.0.12 +aioesphomeapi==18.1.0 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/tests/components/esphome/test_text.py b/tests/components/esphome/test_text.py new file mode 100644 index 00000000000..07157d98ac6 --- /dev/null +++ b/tests/components/esphome/test_text.py @@ -0,0 +1,115 @@ +"""Test ESPHome texts.""" + +from unittest.mock import call + +from aioesphomeapi import APIClient, TextInfo, TextMode as ESPHomeTextMode, TextState + +from homeassistant.components.text import ( + ATTR_VALUE, + DOMAIN as TEXT_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant + + +async def test_generic_text_entity( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="hello world")] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == "hello world" + + await hass.services.async_call( + TEXT_DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: "text.test_mytext", ATTR_VALUE: "goodbye"}, + blocking=True, + ) + mock_client.text_command.assert_has_calls([call(1, "goodbye")]) + mock_client.text_command.reset_mock() + + +async def test_generic_text_entity_no_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_generic_text_entity_missing_state( + hass: HomeAssistant, + mock_client: APIClient, + mock_generic_device_entry, +) -> None: + """Test a generic text entity that has no state.""" + entity_info = [ + TextInfo( + object_id="mytext", + key=1, + name="my text", + unique_id="my_text", + max_length=100, + min_length=0, + pattern=None, + mode=ESPHomeTextMode.TEXT, + ) + ] + states = [TextState(key=1, state="", missing_state=True)] + user_service = [] + await mock_generic_device_entry( + mock_client=mock_client, + entity_info=entity_info, + user_service=user_service, + states=states, + ) + state = hass.states.get("text.test_mytext") + assert state is not None + assert state.state == STATE_UNKNOWN From 704881743bad50dc6bd13dc7c69341f9e95d8176 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 06:24:23 +0200 Subject: [PATCH 837/968] Use real devices in remote device trigger tests (#102693) --- .../components/remote/test_device_trigger.py | 43 +++++++++++++++---- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index b5dcca3dc4c..711b9672aa0 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -177,12 +177,21 @@ async def test_get_trigger_capabilities_legacy( async def test_if_fires_on_state_change( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -195,7 +204,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_on", }, @@ -219,7 +228,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", }, @@ -243,7 +252,7 @@ async def test_if_fires_on_state_change( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "changed_states", }, @@ -287,12 +296,21 @@ async def test_if_fires_on_state_change( async def test_if_fires_on_state_change_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off triggers firing.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -305,7 +323,7 @@ async def test_if_fires_on_state_change_legacy( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "turned_off", }, @@ -341,12 +359,21 @@ async def test_if_fires_on_state_change_legacy( async def test_if_fires_on_state_change_with_for( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for triggers firing with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -359,7 +386,7 @@ async def test_if_fires_on_state_change_with_for( "trigger": { "platform": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "turned_off", "for": {"seconds": 5}, From fb13d9ce7cbbe450385a487647a69e459b66f1cb Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 25 Oct 2023 06:27:46 +0200 Subject: [PATCH 838/968] Set Fronius entities to "unknown" when receiving invalid zero value (#102270) --- homeassistant/components/fronius/sensor.py | 18 ++++++++- tests/components/fronius/__init__.py | 47 +++++++++++++++++----- tests/components/fronius/test_sensor.py | 16 ++++++++ 3 files changed, 70 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 6d5e43a94ee..dfc76ae1415 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -99,6 +99,9 @@ class FroniusSensorEntityDescription(SensorEntityDescription): """Describes Fronius sensor entity.""" default_value: StateType | None = None + # Gen24 devices may report 0 for total energy while doing firmware updates. + # Handling such values shall mitigate spikes in delta calculations. + invalid_when_falsy: bool = False INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ @@ -119,6 +122,7 @@ INVERTER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_ac", @@ -253,6 +257,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_reactive_ac_produced", @@ -260,6 +265,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ state_class=SensorStateClass.TOTAL_INCREASING, icon="mdi:lightning-bolt-outline", entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_minus", @@ -267,6 +273,7 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_ac_plus", @@ -274,18 +281,21 @@ METER_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_consumed", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="energy_real_produced", native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="frequency_phase_average", @@ -461,6 +471,7 @@ OHMPILOT_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, ), FroniusSensorEntityDescription( key="power_real_ac", @@ -508,6 +519,7 @@ POWER_FLOW_ENTITY_DESCRIPTIONS: list[FroniusSensorEntityDescription] = [ native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, + invalid_when_falsy=True, entity_registry_enabled_default=False, ), FroniusSensorEntityDescription( @@ -648,6 +660,8 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn ]["value"] if new_value is None: return self.entity_description.default_value + if self.entity_description.invalid_when_falsy and not new_value: + raise ValueError(f"Ignoring zero value for {self.entity_id}.") if isinstance(new_value, float): return round(new_value, 4) return new_value @@ -657,8 +671,10 @@ class _FroniusSensorEntity(CoordinatorEntity["FroniusCoordinatorBase"], SensorEn """Handle updated data from the coordinator.""" try: self._attr_native_value = self._get_entity_value() - except KeyError: + except (KeyError, ValueError): # sets state to `None` if no default_value is defined in entity description + # KeyError: raised when omitted in response - eg. at night when no production + # ValueError: raised when invalid zero value received self._attr_native_value = self.entity_description.default_value self.async_write_ha_state() diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 5a757da1e9c..c64972b7904 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -1,6 +1,10 @@ """Tests for the Fronius integration.""" from __future__ import annotations +from collections.abc import Callable +import json +from typing import Any + from homeassistant.components.fronius.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST @@ -32,55 +36,78 @@ async def setup_fronius_integration( return entry +def _load_and_patch_fixture( + override_data: dict[str, list[tuple[list[str], Any]]] +) -> Callable[[str, str | None], str]: + """Return a fixture loader that patches values at nested keys for a given filename.""" + + def load_and_patch(filename: str, integration: str): + """Load a fixture and patch given values.""" + text = load_fixture(filename, integration) + if filename not in override_data: + return text + + _loaded = json.loads(text) + for keys, value in override_data[filename]: + _dic = _loaded + for key in keys[:-1]: + _dic = _dic[key] + _dic[keys[-1]] = value + return json.dumps(_loaded) + + return load_and_patch + + def mock_responses( aioclient_mock: AiohttpClientMocker, host: str = MOCK_HOST, fixture_set: str = "symo", inverter_ids: list[str | int] = [1], night: bool = False, + override_data: dict[str, list[tuple[list[str], Any]]] + | None = None, # {filename: [([list of nested keys], patch_value)]} ) -> None: """Mock responses for Fronius devices.""" aioclient_mock.clear_requests() _night = "_night" if night else "" + _load = _load_and_patch_fixture(override_data) if override_data else load_fixture aioclient_mock.get( f"{host}/solar_api/GetAPIVersion.cgi", - text=load_fixture(f"{fixture_set}/GetAPIVersion.json", "fronius"), + text=_load(f"{fixture_set}/GetAPIVersion.json", "fronius"), ) for inverter_id in inverter_ids: aioclient_mock.get( f"{host}/solar_api/v1/GetInverterRealtimeData.cgi?Scope=Device&" f"DeviceId={inverter_id}&DataCollection=CommonInverterData", - text=load_fixture( + text=_load( f"{fixture_set}/GetInverterRealtimeData_Device_{inverter_id}{_night}.json", "fronius", ), ) aioclient_mock.get( f"{host}/solar_api/v1/GetInverterInfo.cgi", - text=load_fixture(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), + text=_load(f"{fixture_set}/GetInverterInfo{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetLoggerInfo.cgi", - text=load_fixture(f"{fixture_set}/GetLoggerInfo.json", "fronius"), + text=_load(f"{fixture_set}/GetLoggerInfo.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetMeterRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetMeterRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetPowerFlowRealtimeData.fcgi", - text=load_fixture( - f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius" - ), + text=_load(f"{fixture_set}/GetPowerFlowRealtimeData{_night}.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetStorageRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetStorageRealtimeData.json", "fronius"), ) aioclient_mock.get( f"{host}/solar_api/v1/GetOhmPilotRealtimeData.cgi?Scope=System", - text=load_fixture(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), + text=_load(f"{fixture_set}/GetOhmPilotRealtimeData.json", "fronius"), ) diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index c2e0c4ad969..f94b0f3a55c 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -302,6 +302,22 @@ async def test_gen24( assert_state("sensor.solarnet_relative_autonomy", 5.3592) assert_state("sensor.solarnet_total_energy", 1530193.42) + # Gen24 devices may report 0 for total energy while doing firmware updates. + # This should yield "unknown" state instead of 0. + mock_responses( + aioclient_mock, + fixture_set="gen24", + override_data={ + "gen24/GetInverterRealtimeData_Device_1.json": [ + (["Body", "Data", "TOTAL_ENERGY", "Value"], 0), + ], + }, + ) + freezer.tick(FroniusInverterUpdateCoordinator.default_interval) + async_fire_time_changed(hass) + await hass.async_block_till_done() + assert_state("sensor.inverter_name_total_energy", "unknown") + async def test_gen24_storage( hass: HomeAssistant, From 0cb0e3ceebe093032d51aef077bfb42104b023a5 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 24 Oct 2023 21:30:29 -0700 Subject: [PATCH 839/968] Add Google tasks integration, with initial read-only To-do list (#102629) * Add Google Tasks integration * Update tests and unique id * Revert devcontainer change * Increase test coverage * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove ternary * Fix JSON --------- Co-authored-by: Martin Hjelmare --- CODEOWNERS | 2 + homeassistant/brands/google.json | 1 + .../components/google_tasks/__init__.py | 46 +++++ homeassistant/components/google_tasks/api.py | 53 ++++++ .../google_tasks/application_credentials.py | 23 +++ .../components/google_tasks/config_flow.py | 30 ++++ .../components/google_tasks/const.py | 16 ++ .../components/google_tasks/coordinator.py | 38 ++++ .../components/google_tasks/manifest.json | 10 ++ .../components/google_tasks/strings.json | 24 +++ homeassistant/components/google_tasks/todo.py | 75 ++++++++ .../generated/application_credentials.py | 1 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + tests/components/google_tasks/__init__.py | 1 + tests/components/google_tasks/conftest.py | 91 ++++++++++ .../google_tasks/test_config_flow.py | 66 +++++++ tests/components/google_tasks/test_init.py | 99 +++++++++++ tests/components/google_tasks/test_todo.py | 165 ++++++++++++++++++ 21 files changed, 750 insertions(+) create mode 100644 homeassistant/components/google_tasks/__init__.py create mode 100644 homeassistant/components/google_tasks/api.py create mode 100644 homeassistant/components/google_tasks/application_credentials.py create mode 100644 homeassistant/components/google_tasks/config_flow.py create mode 100644 homeassistant/components/google_tasks/const.py create mode 100644 homeassistant/components/google_tasks/coordinator.py create mode 100644 homeassistant/components/google_tasks/manifest.json create mode 100644 homeassistant/components/google_tasks/strings.json create mode 100644 homeassistant/components/google_tasks/todo.py create mode 100644 tests/components/google_tasks/__init__.py create mode 100644 tests/components/google_tasks/conftest.py create mode 100644 tests/components/google_tasks/test_config_flow.py create mode 100644 tests/components/google_tasks/test_init.py create mode 100644 tests/components/google_tasks/test_todo.py diff --git a/CODEOWNERS b/CODEOWNERS index 62dccee04c7..6f76291fce8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -477,6 +477,8 @@ build.json @home-assistant/supervisor /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob /tests/components/google_sheets/ @tkdrob +/homeassistant/components/google_tasks/ @allenporter +/tests/components/google_tasks/ @allenporter /homeassistant/components/google_travel_time/ @eifinger /tests/components/google_travel_time/ @eifinger /homeassistant/components/govee_ble/ @bdraco @PierreAronnax diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index ce71457a656..7c6ebc044e9 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -11,6 +11,7 @@ "google_maps", "google_pubsub", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "google_wifi", diff --git a/homeassistant/components/google_tasks/__init__.py b/homeassistant/components/google_tasks/__init__.py new file mode 100644 index 00000000000..da6fc85b287 --- /dev/null +++ b/homeassistant/components/google_tasks/__init__.py @@ -0,0 +1,46 @@ +"""The Google Tasks integration.""" +from __future__ import annotations + +from aiohttp import ClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_entry_oauth2_flow + +from . import api +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.TODO] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Tasks from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + + implementation = ( + await config_entry_oauth2_flow.async_get_config_entry_implementation( + hass, entry + ) + ) + session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) + auth = api.AsyncConfigEntryAuth(hass, session) + try: + await auth.async_get_access_token() + except ClientError as err: + raise ConfigEntryNotReady from err + + hass.data[DOMAIN][entry.entry_id] = auth + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py new file mode 100644 index 00000000000..72b96873b95 --- /dev/null +++ b/homeassistant/components/google_tasks/api.py @@ -0,0 +1,53 @@ +"""API for Google Tasks bound to Home Assistant OAuth.""" + +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +MAX_TASK_RESULTS = 100 + + +class AsyncConfigEntryAuth: + """Provide Google Tasks authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize Google Tasks Auth.""" + self._hass = hass + self._oauth_session = oauth2_session + + async def async_get_access_token(self) -> str: + """Return a valid access token.""" + if not self._oauth_session.valid_token: + await self._oauth_session.async_ensure_token_valid() + return self._oauth_session.token[CONF_ACCESS_TOKEN] + + async def _get_service(self) -> Resource: + """Get current resource.""" + token = await self.async_get_access_token() + return build("tasks", "v1", credentials=Credentials(token=token)) + + async def list_task_lists(self) -> list[dict[str, Any]]: + """Get all TaskList resources.""" + service = await self._get_service() + cmd: HttpRequest = service.tasklists().list() + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] + + async def list_tasks(self, task_list_id: str) -> list[dict[str, Any]]: + """Get all Task resources for the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().list( + tasklist=task_list_id, maxResults=MAX_TASK_RESULTS + ) + result = await self._hass.async_add_executor_job(cmd.execute) + return result["items"] diff --git a/homeassistant/components/google_tasks/application_credentials.py b/homeassistant/components/google_tasks/application_credentials.py new file mode 100644 index 00000000000..223e723f258 --- /dev/null +++ b/homeassistant/components/google_tasks/application_credentials.py @@ -0,0 +1,23 @@ +"""Application credentials platform for the Google Tasks integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google_tasks/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py new file mode 100644 index 00000000000..77570f0377f --- /dev/null +++ b/homeassistant/components/google_tasks/config_flow.py @@ -0,0 +1,30 @@ +"""Config flow for Google Tasks.""" +import logging +from typing import Any + +from homeassistant.helpers import config_entry_oauth2_flow + +from .const import DOMAIN, OAUTH2_SCOPES + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google Tasks OAuth2 authentication.""" + + DOMAIN = DOMAIN + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(OAUTH2_SCOPES), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } diff --git a/homeassistant/components/google_tasks/const.py b/homeassistant/components/google_tasks/const.py new file mode 100644 index 00000000000..87253486127 --- /dev/null +++ b/homeassistant/components/google_tasks/const.py @@ -0,0 +1,16 @@ +"""Constants for the Google Tasks integration.""" + +from enum import StrEnum + +DOMAIN = "google_tasks" + +OAUTH2_AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth" +OAUTH2_TOKEN = "https://oauth2.googleapis.com/token" +OAUTH2_SCOPES = ["https://www.googleapis.com/auth/tasks"] + + +class TaskStatus(StrEnum): + """Status of a Google Task.""" + + NEEDS_ACTION = "needsAction" + COMPLETED = "completed" diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py new file mode 100644 index 00000000000..9997c0d3460 --- /dev/null +++ b/homeassistant/components/google_tasks/coordinator.py @@ -0,0 +1,38 @@ +"""Coordinator for fetching data from Google Tasks API.""" + +import asyncio +import datetime +import logging +from typing import Any, Final + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .api import AsyncConfigEntryAuth + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) +TIMEOUT = 10 + + +class TaskUpdateCoordinator(DataUpdateCoordinator): + """Coordinator for fetching Google Tasks for a Task List form the API.""" + + def __init__( + self, hass: HomeAssistant, api: AsyncConfigEntryAuth, task_list_id: str + ) -> None: + """Initialize TaskUpdateCoordinator.""" + super().__init__( + hass, + _LOGGER, + name=f"Google Tasks {task_list_id}", + update_interval=UPDATE_INTERVAL, + ) + self._api = api + self._task_list_id = task_list_id + + async def _async_update_data(self) -> list[dict[str, Any]]: + """Fetch tasks from API endpoint.""" + async with asyncio.timeout(TIMEOUT): + return await self._api.list_tasks(self._task_list_id) diff --git a/homeassistant/components/google_tasks/manifest.json b/homeassistant/components/google_tasks/manifest.json new file mode 100644 index 00000000000..08f2a54d051 --- /dev/null +++ b/homeassistant/components/google_tasks/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "google_tasks", + "name": "Google Tasks", + "codeowners": ["@allenporter"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/google_tasks", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json new file mode 100644 index 00000000000..e7dbbc2b625 --- /dev/null +++ b/homeassistant/components/google_tasks/strings.json @@ -0,0 +1,24 @@ +{ + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Tasks. You also need to create Application Credentials linked to your account:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web application** for the Application Type.\n\n" + }, + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py new file mode 100644 index 00000000000..98b84943b80 --- /dev/null +++ b/homeassistant/components/google_tasks/todo.py @@ -0,0 +1,75 @@ +"""Google Tasks todo platform.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .api import AsyncConfigEntryAuth +from .const import DOMAIN +from .coordinator import TaskUpdateCoordinator + +SCAN_INTERVAL = timedelta(minutes=15) + +TODO_STATUS_MAP = { + "needsAction": TodoItemStatus.NEEDS_ACTION, + "completed": TodoItemStatus.COMPLETED, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Google Tasks todo platform.""" + api: AsyncConfigEntryAuth = hass.data[DOMAIN][entry.entry_id] + task_lists = await api.list_task_lists() + async_add_entities( + ( + GoogleTaskTodoListEntity( + TaskUpdateCoordinator(hass, api, task_list["id"]), + task_list["title"], + entry.entry_id, + task_list["id"], + ) + for task_list in task_lists + ), + True, + ) + + +class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TaskUpdateCoordinator, + name: str, + config_entry_id: str, + task_list_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + super().__init__(coordinator) + self._attr_name = name.capitalize() + self._attr_unique_id = f"{config_entry_id}-{task_list_id}" + + @property + def todo_items(self) -> list[TodoItem] | None: + """Get the current set of To-do items.""" + if self.coordinator.data is None: + return None + return [ + TodoItem( + summary=item["title"], + uid=item["id"], + status=TODO_STATUS_MAP.get( + item.get("status"), TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self.coordinator.data + ] diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index a4db1b4c0de..060080517bf 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [ "google_assistant_sdk", "google_mail", "google_sheets", + "google_tasks", "home_connect", "lametric", "lyric", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 88706ed4c94..99b947d3c52 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -181,6 +181,7 @@ FLOWS = { "google_generative_ai_conversation", "google_mail", "google_sheets", + "google_tasks", "google_translate", "google_travel_time", "govee_ble", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 36ef89216e2..ad3d3f6f05a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2156,6 +2156,12 @@ "iot_class": "cloud_polling", "name": "Google Sheets" }, + "google_tasks": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Tasks" + }, "google_translate": { "integration_type": "hub", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index a4c4edaef92..1b3b4e5547f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -897,6 +897,7 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 765112d3dc5..9bdd24f4509 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -716,6 +716,7 @@ goalzero==0.2.2 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.google_tasks google-api-python-client==2.71.0 # homeassistant.components.google_pubsub diff --git a/tests/components/google_tasks/__init__.py b/tests/components/google_tasks/__init__.py new file mode 100644 index 00000000000..6a6872a350a --- /dev/null +++ b/tests/components/google_tasks/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Tasks integration.""" diff --git a/tests/components/google_tasks/conftest.py b/tests/components/google_tasks/conftest.py new file mode 100644 index 00000000000..60387889aad --- /dev/null +++ b/tests/components/google_tasks/conftest.py @@ -0,0 +1,91 @@ +"""Test fixtures for Google Tasks.""" + + +from collections.abc import Awaitable, Callable +import time +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.google_tasks.const import DOMAIN, OAUTH2_SCOPES +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +FAKE_ACCESS_TOKEN = "some-access-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" +FAKE_AUTH_IMPL = "conftest-imported-cred" + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify platforms to test.""" + return [] + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="token_entry") +def mock_token_entry(expires_at: int) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_ACCESS_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(OAUTH2_SCOPES), + "token_type": "Bearer", + "expires_at": expires_at, + } + + +@pytest.fixture(name="config_entry") +def mock_config_entry(token_entry: dict[str, Any]) -> MockConfigEntry: + """Fixture for a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + "auth_implementation": DOMAIN, + "token": token_entry, + }, + ) + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.fixture(name="integration_setup") +async def mock_integration_setup( + hass: HomeAssistant, + config_entry: MockConfigEntry, + platforms: list[str], +) -> Callable[[], Awaitable[bool]]: + """Fixture to set up the integration.""" + config_entry.add_to_hass(hass) + + async def run() -> bool: + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", platforms): + result = await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return result + + return run diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py new file mode 100644 index 00000000000..b05e1eb108d --- /dev/null +++ b/tests/components/google_tasks/test_config_flow.py @@ -0,0 +1,66 @@ +"""Test the Google Tasks config flow.""" + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.google_tasks.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.async_setup_entry", return_value=True + ) 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 diff --git a/tests/components/google_tasks/test_init.py b/tests/components/google_tasks/test_init.py new file mode 100644 index 00000000000..b486942f70a --- /dev/null +++ b/tests/components/google_tasks/test_init.py @@ -0,0 +1,99 @@ +"""Tests for Google Tasks.""" +from collections.abc import Awaitable, Callable +import http +import time + +import pytest + +from homeassistant.components.google_tasks import DOMAIN +from homeassistant.components.google_tasks.const import OAUTH2_TOKEN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test successful setup and unload.""" + assert config_entry.state is ConfigEntryState.NOT_LOADED + + await integration_setup() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await integration_setup() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.data["token"]["access_token"] == "updated-access-token" + assert config_entry.data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_RETRY, # Will trigger reauth in the future + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["unauthorized", "internal_server_error"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + integration_setup: Callable[[], Awaitable[bool]], + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + setup_credentials: None, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + OAUTH2_TOKEN, + status=status, + ) + + await integration_setup() + + assert config_entry.state is expected_state diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py new file mode 100644 index 00000000000..d5e6be5d3cd --- /dev/null +++ b/tests/components/google_tasks/test_todo.py @@ -0,0 +1,165 @@ +"""Tests for Google Tasks todo platform.""" + + +from collections.abc import Awaitable, Callable +import json +from unittest.mock import patch + +from httplib2 import Response +import pytest + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from tests.typing import WebSocketGenerator + +ENTITY_ID = "todo.my_tasks" +LIST_TASK_LIST_RESPONSE = { + "items": [ + { + "id": "task-list-id-1", + "title": "My tasks", + }, + ] +} + + +@pytest.fixture +def platforms() -> list[str]: + """Fixture to specify platforms to test.""" + return [Platform.TODO] + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next_id() -> int: + nonlocal id + id += 1 + return id + + return next_id + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": ENTITY_ID, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture(name="api_responses") +def mock_api_responses() -> list[dict | list]: + """Fixture for API responses to return during test.""" + return [] + + +@pytest.fixture(autouse=True) +def mock_http_response(api_responses: list[dict | list]) -> None: + """Fixture to fake out http2lib responses.""" + responses = [ + (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) + for api_response in api_responses + ] + with patch("httplib2.Http.request", side_effect=responses): + yield + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [ + {"id": "task-1", "title": "Task 1", "status": "needsAction"}, + {"id": "task-2", "title": "Task 2", "status": "completed"}, + ], + }, + ] + ], +) +async def test_get_items( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [ + { + "uid": "task-1", + "summary": "Task 1", + "status": "needs_action", + }, + { + "uid": "task-2", + "summary": "Task 2", + "status": "completed", + }, + ] + + # State reflect that one task needs action + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "1" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + { + "items": [], + }, + ] + ], +) +async def test_empty_todo_list( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test getting todo list items.""" + + assert await integration_setup() + + await hass_ws_client(hass) + + items = await ws_get_items() + assert items == [] + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" From 7038bd67f710db13099d70dfaa7289dc38729aea Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Wed, 25 Oct 2023 13:46:00 +0900 Subject: [PATCH 840/968] Add Climate to switchbot cloud integration (#101660) --- .coveragerc | 1 + .../components/switchbot_cloud/__init__.py | 66 ++++++---- .../components/switchbot_cloud/climate.py | 118 ++++++++++++++++++ tests/components/switchbot_cloud/test_init.py | 20 ++- 4 files changed, 178 insertions(+), 27 deletions(-) create mode 100644 homeassistant/components/switchbot_cloud/climate.py diff --git a/.coveragerc b/.coveragerc index eaae941c70d..86b92c07a3d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1264,6 +1264,7 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/climate.py homeassistant/components/switchbot_cloud/coordinator.py homeassistant/components/switchbot_cloud/entity.py homeassistant/components/switchbot_cloud/switch.py diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index c34348137e7..8d3b2443b18 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -1,27 +1,28 @@ """The SwitchBot via API integration.""" from asyncio import gather -from dataclasses import dataclass +from dataclasses import dataclass, field from logging import getLogger from switchbot_api import CannotConnect, Device, InvalidAuth, Remote, SwitchBotAPI from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN from .coordinator import SwitchBotCoordinator _LOGGER = getLogger(__name__) -PLATFORMS: list[Platform] = [Platform.SWITCH] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] @dataclass class SwitchbotDevices: """Switchbot devices data.""" - switches: list[Device | Remote] + climates: list[Remote] = field(default_factory=list) + switches: list[Device | Remote] = field(default_factory=list) @dataclass @@ -32,18 +33,47 @@ class SwitchbotCloudData: devices: SwitchbotDevices +@callback def prepare_device( hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote, - coordinators: list[SwitchBotCoordinator], + coordinators_by_id: dict[str, SwitchBotCoordinator], ) -> tuple[Device | Remote, SwitchBotCoordinator]: """Instantiate coordinator and adds to list for gathering.""" - coordinator = SwitchBotCoordinator(hass, api, device) - coordinators.append(coordinator) + coordinator = coordinators_by_id.setdefault( + device.device_id, SwitchBotCoordinator(hass, api, device) + ) return (device, coordinator) +@callback +def make_device_data( + hass: HomeAssistant, + api: SwitchBotAPI, + devices: list[Device | Remote], + coordinators_by_id: dict[str, SwitchBotCoordinator], +) -> SwitchbotDevices: + """Make device data.""" + devices_data = SwitchbotDevices() + for device in devices: + if isinstance(device, Remote) and device.device_type.endswith( + "Air Conditioner" + ): + devices_data.climates.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + if ( + isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ): + devices_data.switches.append( + prepare_device(hass, api, device, coordinators_by_id) + ) + return devices_data + + async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: """Set up SwitchBot via API from a config entry.""" token = config.data[CONF_API_TOKEN] @@ -60,25 +90,15 @@ async def async_setup_entry(hass: HomeAssistant, config: ConfigEntry) -> bool: except CannotConnect as ex: raise ConfigEntryNotReady from ex _LOGGER.debug("Devices: %s", devices) - coordinators: list[SwitchBotCoordinator] = [] + coordinators_by_id: dict[str, SwitchBotCoordinator] = {} hass.data.setdefault(DOMAIN, {}) - data = SwitchbotCloudData( - api=api, - devices=SwitchbotDevices( - switches=[ - prepare_device(hass, api, device, coordinators) - for device in devices - if isinstance(device, Device) - and device.device_type.startswith("Plug") - or isinstance(device, Remote) - ], - ), + hass.data[DOMAIN][config.entry_id] = SwitchbotCloudData( + api=api, devices=make_device_data(hass, api, devices, coordinators_by_id) ) - hass.data[DOMAIN][config.entry_id] = data - for device_type, devices in vars(data.devices).items(): - _LOGGER.debug("%s: %s", device_type, devices) await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) - await gather(*[coordinator.async_refresh() for coordinator in coordinators]) + await gather( + *[coordinator.async_refresh() for coordinator in coordinators_by_id.values()] + ) return True diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py new file mode 100644 index 00000000000..8ad0e1ad43f --- /dev/null +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -0,0 +1,118 @@ +"""Support for SwitchBot Air Conditioner remotes.""" + +from typing import Any + +from switchbot_api import AirConditionerCommands + +import homeassistant.components.climate as FanState +from homeassistant.components.climate import ( + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import DiscoveryInfoType + +from . import SwitchbotCloudData +from .const import DOMAIN +from .entity import SwitchBotCloudEntity + +_SWITCHBOT_HVAC_MODES: dict[HVACMode, int] = { + HVACMode.HEAT_COOL: 1, + HVACMode.COOL: 2, + HVACMode.DRY: 3, + HVACMode.FAN_ONLY: 4, + HVACMode.HEAT: 5, +} + +_DEFAULT_SWITCHBOT_HVAC_MODE = _SWITCHBOT_HVAC_MODES[HVACMode.FAN_ONLY] + +_SWITCHBOT_FAN_MODES: dict[str, int] = { + FanState.FAN_AUTO: 1, + FanState.FAN_LOW: 2, + FanState.FAN_MEDIUM: 3, + FanState.FAN_HIGH: 4, +} + +_DEFAULT_SWITCHBOT_FAN_MODE = _SWITCHBOT_FAN_MODES[FanState.FAN_AUTO] + + +async def async_setup_entry( + hass: HomeAssistant, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up SwitchBot Cloud entry.""" + data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] + async_add_entities( + SwitchBotCloudAirConditionner(data.api, device, coordinator) + for device, coordinator in data.devices.climates + ) + + +class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): + """Representation of a SwitchBot air conditionner, as it is an IR device, we don't know the actual state.""" + + _attr_assumed_state = True + _attr_supported_features = ( + ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_fan_modes = [ + FanState.FAN_AUTO, + FanState.FAN_LOW, + FanState.FAN_MEDIUM, + FanState.FAN_HIGH, + ] + _attr_fan_mode = FanState.FAN_AUTO + _attr_hvac_modes = [ + HVACMode.HEAT_COOL, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT, + ] + _attr_hvac_mode = HVACMode.FAN_ONLY + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_target_temperature = 21 + _attr_name = None + + async def _do_send_command( + self, + hvac_mode: HVACMode | None = None, + fan_mode: str | None = None, + temperature: float | None = None, + ) -> None: + new_temperature = temperature or self._attr_target_temperature + new_mode = _SWITCHBOT_HVAC_MODES.get( + hvac_mode or self._attr_hvac_mode, _DEFAULT_SWITCHBOT_HVAC_MODE + ) + new_fan_speed = _SWITCHBOT_FAN_MODES.get( + fan_mode or self._attr_fan_mode, _DEFAULT_SWITCHBOT_FAN_MODE + ) + await self.send_command( + AirConditionerCommands.SET_ALL, + parameters=f"{new_temperature},{new_mode},{new_fan_speed},on", + ) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set target hvac mode.""" + await self._do_send_command(hvac_mode=hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set target fan mode.""" + await self._do_send_command(fan_mode=fan_mode) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set target temperature.""" + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + return + await self._do_send_command(temperature=temperature) + self._attr_target_temperature = temperature diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py index 48f0021bdb4..e9f0a0a475d 100644 --- a/tests/components/switchbot_cloud/test_init.py +++ b/tests/components/switchbot_cloud/test_init.py @@ -3,7 +3,7 @@ from unittest.mock import patch import pytest -from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState, Remote from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState @@ -32,12 +32,24 @@ async def test_setup_entry_success( ) -> None: """Test successful setup of entry.""" mock_list_devices.return_value = [ + Remote( + deviceId="air-conditonner-id-1", + deviceName="air-conditonner-name-1", + remoteType="Air Conditioner", + hubDeviceId="test-hub-id", + ), Device( - deviceId="test-id", - deviceName="test-name", + deviceId="plug-id-1", + deviceName="plug-name-1", deviceType="Plug", hubDeviceId="test-hub-id", - ) + ), + Remote( + deviceId="plug-id-2", + deviceName="plug-name-2", + remoteType="DIY Plug", + hubDeviceId="test-hub-id", + ), ] mock_get_status.return_value = {"power": PowerState.ON.value} entry = configure_integration(hass) From b37e9bc79a95319cef7ba47304f308e3c3966d32 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 25 Oct 2023 00:50:10 -0400 Subject: [PATCH 841/968] Improve camera snap performance in Blink (#102652) --- homeassistant/components/blink/camera.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py index 31c4e4a563e..c967ff59c8c 100644 --- a/homeassistant/components/blink/camera.py +++ b/homeassistant/components/blink/camera.py @@ -25,6 +25,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_VIDEO_CLIP = "video" ATTR_IMAGE = "image" +PARALLEL_UPDATES = 1 async def async_setup_entry( @@ -105,6 +106,7 @@ class BlinkCamera(CoordinatorEntity[BlinkUpdateCoordinator], Camera): """Trigger camera to take a snapshot.""" with contextlib.suppress(asyncio.TimeoutError): await self._camera.snap_picture() + await self._coordinator.api.refresh() self.async_write_ha_state() def camera_image( From 4bf475185e53654404047cbc4b647dee6064f169 Mon Sep 17 00:00:00 2001 From: buzz-tee <11776936+buzz-tee@users.noreply.github.com> Date: Wed, 25 Oct 2023 07:12:55 +0200 Subject: [PATCH 842/968] Fix invalid sources in media player sources list (#102646) --- homeassistant/components/frontier_silicon/media_player.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 641a267e987..223abe26e55 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -126,7 +126,8 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._attr_source_list: self.__modes_by_label = { - mode.label: mode.key for mode in await afsapi.get_modes() + (mode.label if mode.label else mode.id): mode.key + for mode in await afsapi.get_modes() } self._attr_source_list = list(self.__modes_by_label) From 93a8b60c2b90e438ba798f14644774b2dfcd65e1 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 25 Oct 2023 07:46:49 +0200 Subject: [PATCH 843/968] Philips Hue restore brightness after transition (#101293) --- homeassistant/components/hue/v2/group.py | 17 +++++++++++ homeassistant/components/hue/v2/light.py | 14 +++++++++ tests/components/hue/test_light_v2.py | 37 +++++++++++++++++++++--- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/hue/v2/group.py b/homeassistant/components/hue/v2/group.py index 7d63df131d8..8ce6d287551 100644 --- a/homeassistant/components/hue/v2/group.py +++ b/homeassistant/components/hue/v2/group.py @@ -96,6 +96,8 @@ class GroupedHueLight(HueBaseEntity, LightEntity): self.api: HueBridgeV2 = bridge.api self._attr_supported_features |= LightEntityFeature.FLASH self._attr_supported_features |= LightEntityFeature.TRANSITION + self._restore_brightness: float | None = None + self._brightness_pct: float = 0 # we create a virtual service/device for Hue zones/rooms # so we have a parent for grouped lights and scenes self._attr_device_info = DeviceInfo( @@ -153,6 +155,18 @@ class GroupedHueLight(HueBaseEntity, LightEntity): brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) flash = kwargs.get(ATTR_FLASH) + if self._restore_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._restore_brightness + self._restore_brightness = None + if flash is not None: await self.async_set_flash(flash) return @@ -170,6 +184,8 @@ class GroupedHueLight(HueBaseEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None: + self._restore_brightness = self._brightness_pct flash = kwargs.get(ATTR_FLASH) if flash is not None: @@ -244,6 +260,7 @@ class GroupedHueLight(HueBaseEntity, LightEntity): if len(supported_color_modes) == 0: # only add color mode brightness if no color variants supported_color_modes.add(ColorMode.BRIGHTNESS) + self._brightness_pct = total_brightness / lights_with_dimming_support self._attr_brightness = round( ((total_brightness / lights_with_dimming_support) / 100) * 255 ) diff --git a/homeassistant/components/hue/v2/light.py b/homeassistant/components/hue/v2/light.py index ed5d0151b03..348d60d8de2 100644 --- a/homeassistant/components/hue/v2/light.py +++ b/homeassistant/components/hue/v2/light.py @@ -94,6 +94,7 @@ class HueLight(HueBaseEntity, LightEntity): self._supported_color_modes.add(ColorMode.BRIGHTNESS) # support transition if brightness control self._attr_supported_features |= LightEntityFeature.TRANSITION + self._last_brightness: float | None = None self._color_temp_active: bool = False # get list of supported effects (combine effects and timed_effects) self._attr_effect_list = [] @@ -209,6 +210,17 @@ class HueLight(HueBaseEntity, LightEntity): xy_color = kwargs.get(ATTR_XY_COLOR) color_temp = normalize_hue_colortemp(kwargs.get(ATTR_COLOR_TEMP)) brightness = normalize_hue_brightness(kwargs.get(ATTR_BRIGHTNESS)) + if self._last_brightness and brightness is None: + # The Hue bridge sets the brightness to 1% when turning on a bulb + # when a transition was used to turn off the bulb. + # This issue has been reported on the Hue forum several times: + # https://developers.meethue.com/forum/t/brightness-turns-down-to-1-automatically-shortly-after-sending-off-signal-hue-bug/5692 + # https://developers.meethue.com/forum/t/lights-turn-on-with-lowest-brightness-via-siri-if-turned-off-via-api/6700 + # https://developers.meethue.com/forum/t/using-transitiontime-with-on-false-resets-bri-to-1/4585 + # https://developers.meethue.com/forum/t/bri-value-changing-in-switching-lights-on-off/6323 + # https://developers.meethue.com/forum/t/fade-in-fade-out/6673 + brightness = self._last_brightness + self._last_brightness = None self._color_temp_active = color_temp is not None flash = kwargs.get(ATTR_FLASH) effect = effect_str = kwargs.get(ATTR_EFFECT) @@ -245,6 +257,8 @@ class HueLight(HueBaseEntity, LightEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" transition = normalize_hue_transition(kwargs.get(ATTR_TRANSITION)) + if transition is not None and self.resource.dimming: + self._last_brightness = self.resource.dimming.brightness flash = kwargs.get(ATTR_FLASH) if flash is not None: diff --git a/tests/components/hue/test_light_v2.py b/tests/components/hue/test_light_v2.py index 1dd20bc1350..c32abecbd0b 100644 --- a/tests/components/hue/test_light_v2.py +++ b/tests/components/hue/test_light_v2.py @@ -217,6 +217,7 @@ async def test_light_turn_off_service( # verify the light is on before we start assert hass.states.get(test_light_id).state == "on" + brightness_pct = hass.states.get(test_light_id).attributes["brightness"] / 255 * 100 # now call the HA turn_off service await hass.services.async_call( @@ -256,6 +257,23 @@ async def test_light_turn_off_service( assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[1]["json"]["dynamics"]["duration"] == 200 + # test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 3 + assert mock_bridge_v2.mock_requests[2]["json"]["on"]["on"] is True + assert ( + round( + mock_bridge_v2.mock_requests[2]["json"]["dimming"]["brightness"] + - brightness_pct + ) + == 0 + ) + # test again with sending long flash await hass.services.async_call( "light", @@ -263,8 +281,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "long"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 3 - assert mock_bridge_v2.mock_requests[2]["json"]["alert"]["action"] == "breathe" + assert len(mock_bridge_v2.mock_requests) == 4 + assert mock_bridge_v2.mock_requests[3]["json"]["alert"]["action"] == "breathe" # test again with sending short flash await hass.services.async_call( @@ -273,8 +291,8 @@ async def test_light_turn_off_service( {"entity_id": test_light_id, "flash": "short"}, blocking=True, ) - assert len(mock_bridge_v2.mock_requests) == 4 - assert mock_bridge_v2.mock_requests[3]["json"]["identify"]["action"] == "identify" + assert len(mock_bridge_v2.mock_requests) == 5 + assert mock_bridge_v2.mock_requests[4]["json"]["identify"]["action"] == "identify" async def test_light_added(hass: HomeAssistant, mock_bridge_v2) -> None: @@ -481,6 +499,17 @@ async def test_grouped_lights( assert mock_bridge_v2.mock_requests[0]["json"]["on"]["on"] is False assert mock_bridge_v2.mock_requests[0]["json"]["dynamics"]["duration"] == 200 + # Test turn_on resets brightness + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": test_light_id}, + blocking=True, + ) + assert len(mock_bridge_v2.mock_requests) == 2 + assert mock_bridge_v2.mock_requests[1]["json"]["on"]["on"] is True + assert mock_bridge_v2.mock_requests[1]["json"]["dimming"]["brightness"] == 100 + # Test sending short flash effect to a grouped light mock_bridge_v2.mock_requests.clear() test_light_id = "light.test_zone" From c89acf2abe64ebabadf47aff5192f91daf985378 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 25 Oct 2023 06:59:45 +0000 Subject: [PATCH 844/968] Bump `nextdns` to version 2.0.0 (#102674) --- homeassistant/components/nextdns/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 2f13632dc46..ddd2e400dab 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==1.4.0"] + "requirements": ["nextdns==2.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1b3b4e5547f..c232629d62a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1292,7 +1292,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump nibe==2.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bdd24f4509..9e3242af569 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1009,7 +1009,7 @@ nextcloudmonitor==1.4.0 nextcord==2.0.0a8 # homeassistant.components.nextdns -nextdns==1.4.0 +nextdns==2.0.0 # homeassistant.components.nibe_heatpump nibe==2.4.0 From b38692f3a72264fbc04bffd1deaa3f00f8f8e6bf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 09:42:00 +0200 Subject: [PATCH 845/968] Use real devices in lock device condition tests (#102757) --- .../components/lock/test_device_condition.py | 42 ++++++++++++++----- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 43513930f2e..59dcbcb4629 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -129,10 +129,21 @@ async def test_get_conditions_hidden_auxiliary( async def test_if_state( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -147,7 +158,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locked", } @@ -165,7 +176,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocked", } @@ -183,7 +194,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_unlocking", } @@ -201,7 +212,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_locking", } @@ -219,7 +230,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_jammed", } @@ -267,10 +278,21 @@ async def test_if_state( async def test_if_state_legacy( - hass: HomeAssistant, entity_registry: er.EntityRegistry, calls + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + calls, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_LOCKED) @@ -285,7 +307,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_locked", } From a6c5927976f208a35ec81bec912f63aaa25c3688 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 09:42:35 +0200 Subject: [PATCH 846/968] Use real devices in light device condition tests (#102756) --- .../components/light/test_device_condition.py | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index b38c225347a..000784ce63c 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -179,12 +179,21 @@ async def test_get_condition_capabilities_legacy( async def test_if_state( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -199,7 +208,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_on", } @@ -218,7 +227,7 @@ async def test_if_state( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", } @@ -253,12 +262,21 @@ async def test_if_state( async def test_if_state_legacy( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for turn_on and turn_off conditions.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -273,7 +291,7 @@ async def test_if_state_legacy( { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.entity_id, "type": "is_on", } @@ -301,12 +319,21 @@ async def test_if_state_legacy( async def test_if_fires_on_for_condition( hass: HomeAssistant, + device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, calls, enable_custom_integrations: None, ) -> None: """Test for firing if condition is on with delay.""" - entry = entity_registry.async_get_or_create(DOMAIN, "test", "5678") + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entry = entity_registry.async_get_or_create( + DOMAIN, "test", "5678", device_id=device_entry.id + ) hass.states.async_set(entry.entity_id, STATE_ON) @@ -333,7 +360,7 @@ async def test_if_fires_on_for_condition( "condition": { "condition": "device", "domain": DOMAIN, - "device_id": "", + "device_id": device_entry.id, "entity_id": entry.id, "type": "is_off", "for": {"seconds": 5}, From 37cde54b2b03341f8a1b8f778b81e1cbb14ce340 Mon Sep 17 00:00:00 2001 From: tzagim <2285958+tzagim@users.noreply.github.com> Date: Wed, 25 Oct 2023 10:48:47 +0300 Subject: [PATCH 847/968] Fix typo in Todoist translations strings 'data' -> 'date' (#102760) --- homeassistant/components/todoist/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 123b5d07ed7..68c2305d073 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -48,7 +48,7 @@ "description": "The day this task is due, in natural language." }, "due_date_lang": { - "name": "Due data language", + "name": "Due date language", "description": "The language of due_date_string." }, "due_date": { From ffed1e82742a1805bcfcee4387aa0b619b37b8e3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Oct 2023 10:28:22 +0200 Subject: [PATCH 848/968] Improve exception handling for Comelit (#102762) improve exception handling for Comelit --- homeassistant/components/comelit/coordinator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 1fc4b0e6668..68592ad6ccc 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -1,11 +1,9 @@ """Support for Comelit.""" -import asyncio from datetime import timedelta from typing import Any -from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject +from aiocomelit import ComeliteSerialBridgeApi, ComelitSerialBridgeObject, exceptions from aiocomelit.const import BRIDGE -import aiohttp from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -73,8 +71,9 @@ class ComelitSerialBridge(DataUpdateCoordinator): logged = False try: logged = await self.api.login() - except (asyncio.exceptions.TimeoutError, aiohttp.ClientConnectorError) as err: + except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) + await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err finally: if not logged: From 7f7064ce596b5718ab3f1cb81d1863621eb829f3 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 25 Oct 2023 01:51:21 -0700 Subject: [PATCH 849/968] Add Google Tasks create and update for todo platform (#102754) * Add Google Tasks create and update for todo platform * Update comments * Update comments --- homeassistant/components/google_tasks/api.py | 28 +++ .../components/google_tasks/coordinator.py | 4 +- homeassistant/components/google_tasks/todo.py | 41 +++- .../google_tasks/snapshots/test_todo.ambr | 37 ++++ tests/components/google_tasks/test_todo.py | 179 +++++++++++++++++- 5 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 tests/components/google_tasks/snapshots/test_todo.ambr diff --git a/homeassistant/components/google_tasks/api.py b/homeassistant/components/google_tasks/api.py index 72b96873b95..d42926c3bf6 100644 --- a/homeassistant/components/google_tasks/api.py +++ b/homeassistant/components/google_tasks/api.py @@ -51,3 +51,31 @@ class AsyncConfigEntryAuth: ) result = await self._hass.async_add_executor_job(cmd.execute) return result["items"] + + async def insert( + self, + task_list_id: str, + task: dict[str, Any], + ) -> None: + """Create a new Task resource on the task list.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().insert( + tasklist=task_list_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) + + async def patch( + self, + task_list_id: str, + task_id: str, + task: dict[str, Any], + ) -> None: + """Update a task resource.""" + service = await self._get_service() + cmd: HttpRequest = service.tasks().patch( + tasklist=task_list_id, + task=task_id, + body=task, + ) + await self._hass.async_add_executor_job(cmd.execute) diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py index 9997c0d3460..ab03cd52ec8 100644 --- a/homeassistant/components/google_tasks/coordinator.py +++ b/homeassistant/components/google_tasks/coordinator.py @@ -29,10 +29,10 @@ class TaskUpdateCoordinator(DataUpdateCoordinator): name=f"Google Tasks {task_list_id}", update_interval=UPDATE_INTERVAL, ) - self._api = api + self.api = api self._task_list_id = task_list_id async def _async_update_data(self) -> list[dict[str, Any]]: """Fetch tasks from API endpoint.""" async with asyncio.timeout(TIMEOUT): - return await self._api.list_tasks(self._task_list_id) + return await self.api.list_tasks(self._task_list_id) diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 98b84943b80..62220303932 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -2,8 +2,14 @@ from __future__ import annotations from datetime import timedelta +from typing import cast -from homeassistant.components.todo import TodoItem, TodoItemStatus, TodoListEntity +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -19,6 +25,17 @@ TODO_STATUS_MAP = { "needsAction": TodoItemStatus.NEEDS_ACTION, "completed": TodoItemStatus.COMPLETED, } +TODO_STATUS_MAP_INV = {v: k for k, v in TODO_STATUS_MAP.items()} + + +def _convert_todo_item(item: TodoItem) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes the tasks API.""" + result: dict[str, str] = {} + if item.summary is not None: + result["title"] = item.summary + if item.status is not None: + result["status"] = TODO_STATUS_MAP_INV[item.status] + return result async def async_setup_entry( @@ -45,6 +62,9 @@ class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): """A To-do List representation of the Shopping List.""" _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM | TodoListEntityFeature.UPDATE_TODO_ITEM + ) def __init__( self, @@ -57,6 +77,7 @@ class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): super().__init__(coordinator) self._attr_name = name.capitalize() self._attr_unique_id = f"{config_entry_id}-{task_list_id}" + self._task_list_id = task_list_id @property def todo_items(self) -> list[TodoItem] | None: @@ -73,3 +94,21 @@ class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): ) for item in self.coordinator.data ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + await self.coordinator.api.insert( + self._task_list_id, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update a To-do item.""" + uid: str = cast(str, item.uid) + await self.coordinator.api.patch( + self._task_list_id, + uid, + task=_convert_todo_item(item), + ) + await self.coordinator.async_refresh() diff --git a/tests/components/google_tasks/snapshots/test_todo.ambr b/tests/components/google_tasks/snapshots/test_todo.ambr new file mode 100644 index 00000000000..f24d17a60d1 --- /dev/null +++ b/tests/components/google_tasks/snapshots/test_todo.ambr @@ -0,0 +1,37 @@ +# serializer version: 1 +# name: test_create_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks?alt=json', + 'POST', + ) +# --- +# name: test_create_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "needsAction"}' +# --- +# name: test_partial_update_status[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_status[api_responses0].1 + '{"status": "needsAction"}' +# --- +# name: test_partial_update_title[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_partial_update_title[api_responses0].1 + '{"title": "Soda"}' +# --- +# name: test_update_todo_list_item[api_responses0] + tuple( + 'https://tasks.googleapis.com/tasks/v1/lists/task-list-id-1/tasks/some-task-id?alt=json', + 'PATCH', + ) +# --- +# name: test_update_todo_list_item[api_responses0].1 + '{"title": "Soda", "status": "completed"}' +# --- diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index d5e6be5d3cd..5dc7f10fea0 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -3,11 +3,14 @@ from collections.abc import Awaitable, Callable import json -from unittest.mock import patch +from typing import Any +from unittest.mock import Mock, patch from httplib2 import Response import pytest +from syrupy.assertion import SnapshotAssertion +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -22,6 +25,10 @@ LIST_TASK_LIST_RESPONSE = { }, ] } +EMPTY_RESPONSE = {} +LIST_TASKS_RESPONSE = { + "items": [], +} @pytest.fixture @@ -76,14 +83,14 @@ def mock_api_responses() -> list[dict | list]: @pytest.fixture(autouse=True) -def mock_http_response(api_responses: list[dict | list]) -> None: +def mock_http_response(api_responses: list[dict | list]) -> Mock: """Fixture to fake out http2lib responses.""" responses = [ (Response({}), bytes(json.dumps(api_response), encoding="utf-8")) for api_response in api_responses ] - with patch("httplib2.Http.request", side_effect=responses): - yield + with patch("httplib2.Http.request", side_effect=responses) as mock_response: + yield mock_response @pytest.mark.parametrize( @@ -138,9 +145,7 @@ async def test_get_items( [ [ LIST_TASK_LIST_RESPONSE, - { - "items": [], - }, + LIST_TASKS_RESPONSE, ] ], ) @@ -163,3 +168,163 @@ async def test_empty_todo_list( state = hass.states.get("todo.my_tasks") assert state assert state.state == "0" + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # create + LIST_TASKS_RESPONSE, # refresh after create + ] + ], +) +async def test_create_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Mock, + snapshot: SnapshotAssertion, +) -> None: + """Test for creating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_update_todo_list_item( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for updating a To-do Item.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "summary": "Soda", "status": "completed"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_title( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with title only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "summary": "Soda"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot + + +@pytest.mark.parametrize( + "api_responses", + [ + [ + LIST_TASK_LIST_RESPONSE, + LIST_TASKS_RESPONSE, + EMPTY_RESPONSE, # update + LIST_TASKS_RESPONSE, # refresh after update + ] + ], +) +async def test_partial_update_status( + hass: HomeAssistant, + setup_credentials: None, + integration_setup: Callable[[], Awaitable[bool]], + mock_http_response: Any, + snapshot: SnapshotAssertion, +) -> None: + """Test for partial update with status only.""" + + assert await integration_setup() + + state = hass.states.get("todo.my_tasks") + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": "some-task-id", "status": "needs_action"}, + target={"entity_id": "todo.my_tasks"}, + blocking=True, + ) + assert len(mock_http_response.call_args_list) == 4 + call = mock_http_response.call_args_list[2] + assert call + assert call.args == snapshot + assert call.kwargs.get("body") == snapshot From dfc454d527d555e853c68f206ad4e3d56a12aeb8 Mon Sep 17 00:00:00 2001 From: Jirka Date: Wed, 25 Oct 2023 10:54:43 +0200 Subject: [PATCH 850/968] Remove double full stop from Vulcan translation strings (#102758) --- homeassistant/components/vulcan/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index 4af3ee95e35..814621b5403 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -4,7 +4,7 @@ "already_configured": "That student has already been added.", "all_student_already_configured": "All students have already been added.", "reauth_successful": "Reauth successful", - "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." + "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student." }, "error": { "unknown": "[%key:common::config_flow::error::unknown%]", From 4328f887be6fb67b76630f656396d52ecbff2fd3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Oct 2023 11:19:06 +0200 Subject: [PATCH 851/968] Address late review comments for Comelit login (#102768) --- homeassistant/components/comelit/coordinator.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index 68592ad6ccc..d3bc973429b 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -68,15 +68,13 @@ class ComelitSerialBridge(DataUpdateCoordinator): async def _async_update_data(self) -> dict[str, Any]: """Update device data.""" _LOGGER.debug("Polling Comelit Serial Bridge host: %s", self._host) - logged = False try: - logged = await self.api.login() + await self.api.login() except exceptions.CannotConnect as err: _LOGGER.warning("Connection error for %s", self._host) await self.api.close() raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - finally: - if not logged: - raise ConfigEntryAuthFailed + except exceptions.CannotAuthenticate: + raise ConfigEntryAuthFailed return await self.api.get_all_devices() From 267721af43beeee74416c5f4e5186627b4fe5ac2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 25 Oct 2023 11:28:52 +0200 Subject: [PATCH 852/968] Bump hass-nabucasa from 0.73.0 to 0.74.0 (#102763) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 653ceafdaf0..6d5c954361b 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.73.0"] + "requirements": ["hass-nabucasa==0.74.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ac3245c2ff1..06bfeabb9e4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.4 dbus-fast==2.12.0 fnv-hash-fast==0.5.0 ha-av==10.1.1 -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 home-assistant-frontend==20231005.0 diff --git a/requirements_all.txt b/requirements_all.txt index c232629d62a..4b7f69c3c3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -971,7 +971,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9e3242af569..1bc9847f1b8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -772,7 +772,7 @@ ha-philipsjs==3.1.1 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.73.0 +hass-nabucasa==0.74.0 # homeassistant.components.conversation hassil==1.2.5 From 45e4f71d1ac5b9202061280ab4bf817ac5ad6633 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 12:00:12 +0200 Subject: [PATCH 853/968] Add generics to Withings (#102770) --- homeassistant/components/withings/calendar.py | 6 +- homeassistant/components/withings/entity.py | 8 ++- homeassistant/components/withings/sensor.py | 68 +++++++++++-------- 3 files changed, 49 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 3ee2c7dae59..19572682d1a 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -65,13 +65,13 @@ def get_event_name(category: WorkoutCategory) -> str: return name.replace("_", " ") -class WithingsWorkoutCalendarEntity(CalendarEntity, WithingsEntity): +class WithingsWorkoutCalendarEntity( + CalendarEntity, WithingsEntity[WithingsWorkoutDataUpdateCoordinator] +): """A calendar entity.""" _attr_translation_key = "workout" - coordinator: WithingsWorkoutDataUpdateCoordinator - def __init__( self, client: WithingsClient, coordinator: WithingsWorkoutDataUpdateCoordinator ) -> None: diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index 8d2c815b340..7f3e694533c 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -1,21 +1,25 @@ """Base entity for Withings.""" from __future__ import annotations +from typing import TypeVar + from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import WithingsDataUpdateCoordinator +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) -class WithingsEntity(CoordinatorEntity[WithingsDataUpdateCoordinator]): + +class WithingsEntity(CoordinatorEntity[_T]): """Base class for withings entities.""" _attr_has_entity_name = True def __init__( self, - coordinator: WithingsDataUpdateCoordinator, + coordinator: _T, key: str, ) -> None: """Initialize the Withings entity.""" diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 4729671fa3b..fe4db35cf99 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime +from typing import Generic, TypeVar from aiowithings import ( Activity, @@ -787,26 +788,33 @@ async def async_setup_entry( async_add_entities(entities) -class WithingsSensor(WithingsEntity, SensorEntity): +_T = TypeVar("_T", bound=WithingsDataUpdateCoordinator) +_ED = TypeVar("_ED", bound=SensorEntityDescription) + + +class WithingsSensor(WithingsEntity[_T], SensorEntity, Generic[_T, _ED]): """Implementation of a Withings sensor.""" + entity_description: _ED + def __init__( self, - coordinator: WithingsDataUpdateCoordinator, - entity_description: SensorEntityDescription, + coordinator: _T, + entity_description: _ED, ) -> None: """Initialize sensor.""" super().__init__(coordinator, entity_description.key) self.entity_description = entity_description -class WithingsMeasurementSensor(WithingsSensor): +class WithingsMeasurementSensor( + WithingsSensor[ + WithingsMeasurementDataUpdateCoordinator, + WithingsMeasurementSensorEntityDescription, + ] +): """Implementation of a Withings measurement sensor.""" - coordinator: WithingsMeasurementDataUpdateCoordinator - - entity_description: WithingsMeasurementSensorEntityDescription - @property def native_value(self) -> float: """Return the state of the entity.""" @@ -821,13 +829,14 @@ class WithingsMeasurementSensor(WithingsSensor): ) -class WithingsSleepSensor(WithingsSensor): +class WithingsSleepSensor( + WithingsSensor[ + WithingsSleepDataUpdateCoordinator, + WithingsSleepSensorEntityDescription, + ] +): """Implementation of a Withings sleep sensor.""" - coordinator: WithingsSleepDataUpdateCoordinator - - entity_description: WithingsSleepSensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -836,13 +845,14 @@ class WithingsSleepSensor(WithingsSensor): return self.entity_description.value_fn(self.coordinator.data) -class WithingsGoalsSensor(WithingsSensor): +class WithingsGoalsSensor( + WithingsSensor[ + WithingsGoalsDataUpdateCoordinator, + WithingsGoalsSensorEntityDescription, + ] +): """Implementation of a Withings goals sensor.""" - coordinator: WithingsGoalsDataUpdateCoordinator - - entity_description: WithingsGoalsSensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -850,13 +860,14 @@ class WithingsGoalsSensor(WithingsSensor): return self.entity_description.value_fn(self.coordinator.data) -class WithingsActivitySensor(WithingsSensor): +class WithingsActivitySensor( + WithingsSensor[ + WithingsActivityDataUpdateCoordinator, + WithingsActivitySensorEntityDescription, + ] +): """Implementation of a Withings activity sensor.""" - coordinator: WithingsActivityDataUpdateCoordinator - - entity_description: WithingsActivitySensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" @@ -870,13 +881,14 @@ class WithingsActivitySensor(WithingsSensor): return dt_util.start_of_local_day() -class WithingsWorkoutSensor(WithingsSensor): +class WithingsWorkoutSensor( + WithingsSensor[ + WithingsWorkoutDataUpdateCoordinator, + WithingsWorkoutSensorEntityDescription, + ] +): """Implementation of a Withings workout sensor.""" - coordinator: WithingsWorkoutDataUpdateCoordinator - - entity_description: WithingsWorkoutSensorEntityDescription - @property def native_value(self) -> StateType: """Return the state of the entity.""" From 4812d62ccf69f6b1965d5e55ac96297f9bdc452f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 12:14:03 +0200 Subject: [PATCH 854/968] Bring Withings activity sensor creation in line with the others (#102771) --- homeassistant/components/withings/sensor.py | 30 +++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index fe4db35cf99..1bef72c48ec 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -706,26 +706,28 @@ async def async_setup_entry( activity_coordinator = withings_data.activity_coordinator - activity_callback: Callable[[], None] | None = None - activity_entities_setup_before = ent_reg.async_get_entity_id( Platform.SENSOR, DOMAIN, f"withings_{entry.unique_id}_activity_steps_today" ) - def _async_add_activity_entities() -> None: - """Add activity entities.""" - if activity_coordinator.data is not None or activity_entities_setup_before: - async_add_entities( - WithingsActivitySensor(activity_coordinator, attribute) - for attribute in ACTIVITY_SENSORS - ) - if activity_callback: - activity_callback() - if activity_coordinator.data is not None or activity_entities_setup_before: - _async_add_activity_entities() + entities.extend( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) else: - activity_callback = activity_coordinator.async_add_listener( + remove_activity_listener: Callable[[], None] + + def _async_add_activity_entities() -> None: + """Add activity entities.""" + if activity_coordinator.data is not None: + async_add_entities( + WithingsActivitySensor(activity_coordinator, attribute) + for attribute in ACTIVITY_SENSORS + ) + remove_activity_listener() + + remove_activity_listener = activity_coordinator.async_add_listener( _async_add_activity_entities ) From d2f8c527a5f968fbe3c1fc0ba9d5028e73a238c9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 12:15:09 +0200 Subject: [PATCH 855/968] Add entity translations to Tomorrow.io (#99632) --- .../components/tomorrowio/__init__.py | 2 +- homeassistant/components/tomorrowio/sensor.py | 67 ++++------ .../components/tomorrowio/strings.json | 119 +++++++++++++++--- .../components/tomorrowio/weather.py | 3 +- tests/components/tomorrowio/test_sensor.py | 9 +- tests/components/tomorrowio/test_weather.py | 5 +- 6 files changed, 131 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 626049276f5..25b814c106a 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -327,6 +327,7 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): """Base Tomorrow.io Entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -340,7 +341,6 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): self._config_entry = config_entry self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._config_entry.data[CONF_API_KEY])}, - name=INTEGRATION_NAME, manufacturer=INTEGRATION_NAME, sw_version=f"v{self.api_version}", entry_type=DeviceEntryType.SERVICE, diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 4aa2748ad30..947bbf6fd2f 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -25,7 +25,6 @@ from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, - CONF_NAME, PERCENTAGE, UnitOfIrradiance, UnitOfLength, @@ -75,10 +74,6 @@ from .const import ( class TomorrowioSensorEntityDescription(SensorEntityDescription): """Describes a Tomorrow.io sensor entity.""" - # TomorrowioSensor does not support UNDEFINED or None, - # restrict the type to str. - name: str = "" - attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None @@ -111,16 +106,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="feels_like", + translation_key="feels_like", attribute=TMRW_ATTR_FEELS_LIKE, - name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="dew_point", + translation_key="dew_point", attribute=TMRW_ATTR_DEW_POINT, - name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -130,7 +125,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="pressure_surface_level", attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, - name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, state_class=SensorStateClass.MEASUREMENT, @@ -140,7 +134,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="global_horizontal_irradiance", attribute=TMRW_ATTR_SOLAR_GHI, - name="Global Horizontal Irradiance", unit_imperial=UnitOfIrradiance.BTUS_PER_HOUR_SQUARE_FOOT, unit_metric=UnitOfIrradiance.WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), @@ -150,8 +143,8 @@ SENSOR_TYPES = ( # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_base", + translation_key="cloud_base", attribute=TMRW_ATTR_CLOUD_BASE, - name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -166,8 +159,8 @@ SENSOR_TYPES = ( # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key="cloud_ceiling", + translation_key="cloud_ceiling", attribute=TMRW_ATTR_CLOUD_CEILING, - name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, unit_metric=UnitOfLength.KILOMETERS, @@ -181,16 +174,16 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="cloud_cover", + translation_key="cloud_cover", attribute=TMRW_ATTR_CLOUD_COVER, - name="Cloud Cover", icon="mdi:cloud-percent", native_unit_of_measurement=PERCENTAGE, ), # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key="wind_gust", + translation_key="wind_gust", attribute=TMRW_ATTR_WIND_GUST, - name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, unit_metric=UnitOfSpeed.METERS_PER_SECOND, @@ -202,10 +195,9 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="precipitation_type", - attribute=TMRW_ATTR_PRECIPITATION_TYPE, - name="Precipitation Type", - value_map=PrecipitationType, translation_key="precipitation_type", + attribute=TMRW_ATTR_PRECIPITATION_TYPE, + value_map=PrecipitationType, icon="mdi:weather-snowy-rainy", ), # Data comes in as ppb, convert to µg/m^3 @@ -213,7 +205,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="ozone", attribute=TMRW_ATTR_OZONE, - name="Ozone", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(48), device_class=SensorDeviceClass.OZONE, @@ -222,7 +213,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="particulate_matter_2_5_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_25, - name="Particulate Matter < 2.5 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM25, state_class=SensorStateClass.MEASUREMENT, @@ -230,7 +220,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="particulate_matter_10_mm", attribute=TMRW_ATTR_PARTICULATE_MATTER_10, - name="Particulate Matter < 10 μm", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, state_class=SensorStateClass.MEASUREMENT, @@ -240,7 +229,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="nitrogen_dioxide", attribute=TMRW_ATTR_NITROGEN_DIOXIDE, - name="Nitrogen Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, @@ -250,7 +238,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="carbon_monoxide", attribute=TMRW_ATTR_CARBON_MONOXIDE, - name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, @@ -261,7 +248,6 @@ SENSOR_TYPES = ( TomorrowioSensorEntityDescription( key="sulphur_dioxide", attribute=TMRW_ATTR_SULPHUR_DIOXIDE, - name="Sulphur Dioxide", native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, multiplication_factor=convert_ppb_to_ugm3(64.07), device_class=SensorDeviceClass.SULPHUR_DIOXIDE, @@ -269,90 +255,82 @@ SENSOR_TYPES = ( ), TomorrowioSensorEntityDescription( key="us_epa_air_quality_index", + translation_key="us_epa_air_quality_index", attribute=TMRW_ATTR_EPA_AQI, - name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, state_class=SensorStateClass.MEASUREMENT, ), TomorrowioSensorEntityDescription( key="us_epa_primary_pollutant", - attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, - name="US EPA Primary Pollutant", - value_map=PrimaryPollutantType, translation_key="primary_pollutant", + attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + value_map=PrimaryPollutantType, ), TomorrowioSensorEntityDescription( key="us_epa_health_concern", - attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, - name="US EPA Health Concern", - value_map=HealthConcernType, translation_key="health_concern", + attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, + value_map=HealthConcernType, icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="china_mep_air_quality_index", + translation_key="china_mep_air_quality_index", attribute=TMRW_ATTR_CHINA_AQI, - name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( key="china_mep_primary_pollutant", + translation_key="china_mep_primary_pollutant", attribute=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, - name="China MEP Primary Pollutant", value_map=PrimaryPollutantType, - translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( key="china_mep_health_concern", + translation_key="china_mep_health_concern", attribute=TMRW_ATTR_CHINA_HEALTH_CONCERN, - name="China MEP Health Concern", value_map=HealthConcernType, - translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( key="tree_pollen_index", + translation_key="pollen_index", attribute=TMRW_ATTR_POLLEN_TREE, - name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="weed_pollen_index", + translation_key="weed_pollen_index", attribute=TMRW_ATTR_POLLEN_WEED, - name="Weed Pollen Index", value_map=PollenIndex, - translation_key="pollen_index", icon="mdi:flower-pollen", ), TomorrowioSensorEntityDescription( key="grass_pollen_index", + translation_key="grass_pollen_index", attribute=TMRW_ATTR_POLLEN_GRASS, - name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, - translation_key="pollen_index", ), TomorrowioSensorEntityDescription( key="fire_index", + translation_key="fire_index", attribute=TMRW_ATTR_FIRE_INDEX, - name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( key="uv_index", + translation_key="uv_index", attribute=TMRW_ATTR_UV_INDEX, - name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( key="uv_radiation_health_concern", + translation_key="uv_radiation_health_concern", attribute=TMRW_ATTR_UV_HEALTH_CONCERN, - name="UV Radiation Health Concern", value_map=UVDescription, - translation_key="uv_index", icon="mdi:weather-sunny-alert", ), ) @@ -399,7 +377,6 @@ class BaseTomorrowioSensorEntity(TomorrowioEntity, SensorEntity): """Initialize Tomorrow.io Sensor Entity.""" super().__init__(config_entry, coordinator, api_version) self.entity_description = description - self._attr_name = f"{self._config_entry.data[CONF_NAME]} - {description.name}" self._attr_unique_id = f"{self._config_entry.unique_id}_{description.key}" if self.entity_description.native_unit_of_measurement is None: self._attr_native_unit_of_measurement = description.unit_metric diff --git a/homeassistant/components/tomorrowio/strings.json b/homeassistant/components/tomorrowio/strings.json index a104570f5c8..03a8a169920 100644 --- a/homeassistant/components/tomorrowio/strings.json +++ b/homeassistant/components/tomorrowio/strings.json @@ -33,36 +33,39 @@ }, "entity": { "sensor": { - "health_concern": { - "state": { - "good": "Good", - "moderate": "Moderate", - "unhealthy_for_sensitive_groups": "Unhealthy for Sensitive Groups", - "unhealthy": "Unhealthy", - "very_unhealthy": "Very Unhealthy", - "hazardous": "Hazardous" - } + "feels_like": { + "name": "Feels like" }, - "pollen_index": { - "state": { - "none": "None", - "very_low": "Very Low", - "low": "Low", - "medium": "Medium", - "high": "High", - "very_high": "Very High" - } + "dew_point": { + "name": "Dew point" + }, + "cloud_base": { + "name": "Cloud base" + }, + "cloud_ceiling": { + "name": "Cloud ceiling" + }, + "cloud_cover": { + "name": "Cloud cover" + }, + "wind_gust": { + "name": "Wind gust" }, "precipitation_type": { + "name": "Precipitation type", "state": { "none": "None", "rain": "Rain", "snow": "Snow", - "freezing_rain": "Freezing Rain", - "ice_pellets": "Ice Pellets" + "freezing_rain": "Freezing rain", + "ice_pellets": "Ice pellets" } }, + "us_epa_air_quality_index": { + "name": "US EPA air quality index" + }, "primary_pollutant": { + "name": "US EPA primary pollutant", "state": { "pm25": "[%key:component::sensor::entity_component::pm25::name%]", "pm10": "[%key:component::sensor::entity_component::pm10::name%]", @@ -72,7 +75,83 @@ "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" } }, + "health_concern": { + "name": "US EPA health concern", + "state": { + "good": "Good", + "moderate": "Moderate", + "unhealthy_for_sensitive_groups": "Unhealthy for sensitive groups", + "unhealthy": "Unhealthy", + "very_unhealthy": "Very unhealthy", + "hazardous": "Hazardous" + } + }, + "china_mep_air_quality_index": { + "name": "China MEP air quality index" + }, + "china_mep_primary_pollutant": { + "name": "China MEP primary pollutant", + "state": { + "pm25": "[%key:component::sensor::entity_component::pm25::name%]", + "pm10": "[%key:component::sensor::entity_component::pm10::name%]", + "o3": "[%key:component::sensor::entity_component::ozone::name%]", + "no2": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]", + "co": "[%key:component::sensor::entity_component::carbon_monoxide::name%]", + "so2": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]" + } + }, + "china_mep_health_concern": { + "name": "China MEP health concern", + "state": { + "good": "[%key:component::tomorrowio::entity::sensor::health_concern::state::good%]", + "moderate": "[%key:component::tomorrowio::entity::sensor::health_concern::state::moderate%]", + "unhealthy_for_sensitive_groups": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy_for_sensitive_groups%]", + "unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::unhealthy%]", + "very_unhealthy": "[%key:component::tomorrowio::entity::sensor::health_concern::state::very_unhealthy%]", + "hazardous": "[%key:component::tomorrowio::entity::sensor::health_concern::state::hazardous%]" + } + }, + "pollen_index": { + "name": "Tree pollen index", + "state": { + "none": "None", + "very_low": "Very low", + "low": "Low", + "medium": "Medium", + "high": "High", + "very_high": "Very high" + } + }, + "weed_pollen_index": { + "name": "Weed pollen index", + "state": { + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + } + }, + "grass_pollen_index": { + "name": "Grass pollen index", + "state": { + "none": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::none%]", + "very_low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_low%]", + "low": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::low%]", + "medium": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::medium%]", + "high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::high%]", + "very_high": "[%key:component::tomorrowio::entity::sensor::pollen_index::state::very_high%]" + } + }, + "fire_index": { + "name": "Fire index" + }, "uv_index": { + "name": "UV index" + }, + "uv_radiation_health_concern": { + "name": "UV radiation health concern", "state": { "low": "Low", "moderate": "Moderate", diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index b0b82d81463..06a147366e8 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -24,7 +24,6 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_NAME, UnitOfLength, UnitOfPrecipitationDepth, UnitOfPressure, @@ -118,7 +117,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, SingleCoordinatorWeatherEntity): self._attr_entity_registry_enabled_default = ( forecast_type == DEFAULT_FORECAST_TYPE ) - self._attr_name = f"{config_entry.data[CONF_NAME]} - {forecast_type.title()}" + self._attr_name = forecast_type.title() self._attr_unique_id = _calculate_unique_id( config_entry.unique_id, forecast_type ) diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index 77335769383..53e455ffb8d 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -37,8 +37,8 @@ O3 = "ozone" CO = "carbon_monoxide" NO2 = "nitrogen_dioxide" SO2 = "sulphur_dioxide" -PM25 = "particulate_matter_2_5_mm" -PM10 = "particulate_matter_10_mm" +PM25 = "pm2_5" +PM10 = "pm10" MEP_AQI = "china_mep_air_quality_index" MEP_HEALTH_CONCERN = "china_mep_health_concern" MEP_PRIMARY_POLLUTANT = "china_mep_primary_pollutant" @@ -51,10 +51,10 @@ WEED_POLLEN = "weed_pollen_index" TREE_POLLEN = "tree_pollen_index" FEELS_LIKE = "feels_like" DEW_POINT = "dew_point" -PRESSURE_SURFACE_LEVEL = "pressure_surface_level" +PRESSURE_SURFACE_LEVEL = "pressure" SNOW_ACCUMULATION = "snow_accumulation" ICE_ACCUMULATION = "ice_accumulation" -GHI = "global_horizontal_irradiance" +GHI = "irradiance" CLOUD_BASE = "cloud_base" CLOUD_COVER = "cloud_cover" CLOUD_CEILING = "cloud_ceiling" @@ -121,6 +121,7 @@ async def _setup( data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 229e62065a6..863623ee524 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -78,6 +78,7 @@ async def _setup_config_entry(hass: HomeAssistant, config: dict[str, Any]) -> St data = _get_config_schema(hass, SOURCE_USER)(config) data[CONF_NAME] = DEFAULT_NAME config_entry = MockConfigEntry( + title=DEFAULT_NAME, domain=DOMAIN, data=data, options={CONF_TIMESTEP: DEFAULT_TIMESTEP}, @@ -228,7 +229,7 @@ async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) - ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" @@ -261,7 +262,7 @@ async def test_v4_weather_legacy_entities(hass: HomeAssistant) -> None: ATTR_FORECAST_WIND_BEARING: 239.6, ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } - assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" + assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" From 6fae50cb75196bc846033e9611450c44cc8bd8e5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 25 Oct 2023 12:15:58 +0200 Subject: [PATCH 856/968] Add connections to Xiaomi BLE and BTHome device entry (#102773) --- homeassistant/components/bthome/__init__.py | 7 ++++++- homeassistant/components/xiaomi_ble/__init__.py | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 751c8f74bf9..566609b998b 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -14,7 +14,11 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry, async_get +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( BTHOME_BLE_EVENT, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, diff --git a/homeassistant/components/xiaomi_ble/__init__.py b/homeassistant/components/xiaomi_ble/__init__.py index b12f4df7db1..ced8c3cc471 100644 --- a/homeassistant/components/xiaomi_ble/__init__.py +++ b/homeassistant/components/xiaomi_ble/__init__.py @@ -15,7 +15,11 @@ from homeassistant.components.bluetooth import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import CoreState, HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry, async_get +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceRegistry, + async_get, +) from .const import ( CONF_DISCOVERED_EVENT_CLASSES, @@ -55,6 +59,7 @@ def process_service_info( sensor_device_info = update.devices[device_key.device_id] device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, address)}, identifiers={(BLUETOOTH_DOMAIN, address)}, manufacturer=sensor_device_info.manufacturer, model=sensor_device_info.model, From 0658c7b307adfdc3cd866e34e86e2d231f7c22bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 13:01:27 +0200 Subject: [PATCH 857/968] Add config flow to random (#100858) Co-authored-by: Robert Resch --- homeassistant/components/random/__init__.py | 23 ++ .../components/random/binary_sensor.py | 31 ++- .../components/random/config_flow.py | 186 ++++++++++++++++ homeassistant/components/random/const.py | 5 + homeassistant/components/random/manifest.json | 4 +- homeassistant/components/random/sensor.py | 41 ++-- homeassistant/components/random/strings.json | 48 +++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 12 +- tests/components/random/test_config_flow.py | 201 ++++++++++++++++++ 10 files changed, 524 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/random/config_flow.py create mode 100644 homeassistant/components/random/const.py create mode 100644 homeassistant/components/random/strings.json create mode 100644 tests/components/random/test_config_flow.py diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py index 01bde80b0c3..89a772529bd 100644 --- a/homeassistant/components/random/__init__.py +++ b/homeassistant/components/random/__init__.py @@ -1 +1,24 @@ """The random component.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + await hass.config_entries.async_forward_entry_setups( + entry, (entry.options["entity_type"],) + ) + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms( + entry, (entry.options["entity_type"],) + ) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 5e688162124..33d60d4bfd8 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -1,7 +1,9 @@ """Support for showing random states.""" from __future__ import annotations +from collections.abc import Mapping from random import getrandbits +from typing import Any import voluptuous as vol @@ -10,6 +12,7 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -33,20 +36,32 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random binary sensor.""" - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - async_add_entities([RandomSensor(name, device_class)], True) + async_add_entities([RandomBinarySensor(config)], True) -class RandomSensor(BinarySensorEntity): +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + async_add_entities( + [RandomBinarySensor(config_entry.options, config_entry.entry_id)], True + ) + + +class RandomBinarySensor(BinarySensorEntity): """Representation of a Random binary sensor.""" - def __init__(self, name, device_class): + _state: bool | None = None + + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random binary sensor.""" - self._name = name - self._device_class = device_class - self._state = None + self._name = config.get(CONF_NAME) + self._device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/config_flow.py b/homeassistant/components/random/config_flow.py new file mode 100644 index 00000000000..96dde9c8742 --- /dev/null +++ b/homeassistant/components/random/config_flow.py @@ -0,0 +1,186 @@ +"""Config flow for Random helper.""" +from collections.abc import Callable, Coroutine, Mapping +from enum import StrEnum +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.sensor import DEVICE_CLASS_UNITS, SensorDeviceClass +from homeassistant.const import ( + CONF_DEVICE_CLASS, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, + CONF_UNIT_OF_MEASUREMENT, + Platform, +) +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) + +from .const import DOMAIN +from .sensor import DEFAULT_MAX, DEFAULT_MIN + + +class _FlowType(StrEnum): + CONFIG = "config" + OPTION = "option" + + +def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema: + """Generate schema.""" + schema: dict[vol.Marker, Any] = {} + + if flow_type == _FlowType.CONFIG: + schema[vol.Required(CONF_NAME)] = TextSelector() + + if domain == Platform.BINARY_SENSOR: + schema[vol.Optional(CONF_DEVICE_CLASS)] = SelectSelector( + SelectSelectorConfig( + options=[cls.value for cls in BinarySensorDeviceClass], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="binary_sensor_device_class", + ), + ) + + if domain == Platform.SENSOR: + schema.update( + { + vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int, + vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int, + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[ + cls.value + for cls in SensorDeviceClass + if cls != SensorDeviceClass.ENUM + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_device_class", + ), + ), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector( + SelectSelectorConfig( + options=[ + str(unit) + for units in DEVICE_CLASS_UNITS.values() + for unit in units + if unit is not None + ], + sort=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key="sensor_unit_of_measurement", + custom_value=True, + ), + ), + } + ) + + return vol.Schema(schema) + + +async def choose_options_step(options: dict[str, Any]) -> str: + """Return next step_id for options flow according to template_type.""" + return cast(str, options["entity_type"]) + + +def _validate_unit(options: dict[str, Any]) -> None: + """Validate unit of measurement.""" + if ( + (device_class := options.get(CONF_DEVICE_CLASS)) + and (units := DEVICE_CLASS_UNITS.get(device_class)) + and (unit := options.get(CONF_UNIT_OF_MEASUREMENT)) not in units + ): + sorted_units = sorted( + [f"'{str(unit)}'" if unit else "no unit of measurement" for unit in units], + key=str.casefold, + ) + if len(sorted_units) == 1: + units_string = sorted_units[0] + else: + units_string = f"one of {', '.join(sorted_units)}" + + raise vol.Invalid( + f"'{unit}' is not a valid unit for device class '{device_class}'; " + f"expected {units_string}" + ) + + +def validate_user_input( + template_type: str, +) -> Callable[ + [SchemaCommonFlowHandler, dict[str, Any]], + Coroutine[Any, Any, dict[str, Any]], +]: + """Do post validation of user input. + + For sensors: Validate unit of measurement. + """ + + async def _validate_user_input( + _: SchemaCommonFlowHandler, + user_input: dict[str, Any], + ) -> dict[str, Any]: + """Add template type to user input.""" + if template_type == Platform.SENSOR: + _validate_unit(user_input) + return {"entity_type": template_type} | user_input + + return _validate_user_input + + +RANDOM_TYPES = [ + Platform.BINARY_SENSOR.value, + Platform.SENSOR.value, +] + +CONFIG_FLOW = { + "user": SchemaFlowMenuStep(RANDOM_TYPES), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.CONFIG), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(next_step=choose_options_step), + Platform.BINARY_SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.BINARY_SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.BINARY_SENSOR), + ), + Platform.SENSOR: SchemaFlowFormStep( + _generate_schema(Platform.SENSOR, _FlowType.OPTION), + validate_user_input=validate_user_input(Platform.SENSOR), + ), +} + + +class RandomConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle config flow for random helper.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + @callback + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/random/const.py b/homeassistant/components/random/const.py new file mode 100644 index 00000000000..df6a18f8d11 --- /dev/null +++ b/homeassistant/components/random/const.py @@ -0,0 +1,5 @@ +"""Constants for random helper.""" +DOMAIN = "random" + +DEFAULT_MIN = 0 +DEFAULT_MAX = 20 diff --git a/homeassistant/components/random/manifest.json b/homeassistant/components/random/manifest.json index 164445fd8ed..36396f0a1f6 100644 --- a/homeassistant/components/random/manifest.json +++ b/homeassistant/components/random/manifest.json @@ -2,7 +2,9 @@ "domain": "random", "name": "Random", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/random", - "iot_class": "local_polling", + "integration_type": "helper", + "iot_class": "calculated", "quality_scale": "internal" } diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index d4db30fd61e..18b383b401e 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -1,12 +1,16 @@ """Support for showing random numbers.""" from __future__ import annotations +from collections.abc import Mapping from random import randrange +from typing import Any import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, @@ -17,12 +21,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import DEFAULT_MAX, DEFAULT_MIN + ATTR_MAXIMUM = "maximum" ATTR_MINIMUM = "minimum" DEFAULT_NAME = "Random Sensor" -DEFAULT_MIN = 0 -DEFAULT_MAX = 20 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -42,26 +46,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Random number sensor.""" - name = config.get(CONF_NAME) - minimum = config.get(CONF_MINIMUM) - maximum = config.get(CONF_MAXIMUM) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) - async_add_entities([RandomSensor(name, minimum, maximum, unit)], True) + async_add_entities([RandomSensor(config)], True) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize config entry.""" + + async_add_entities( + [RandomSensor(config_entry.options, config_entry.entry_id)], True + ) class RandomSensor(SensorEntity): """Representation of a Random number sensor.""" _attr_icon = "mdi:hanger" + _state: int | None = None - def __init__(self, name, minimum, maximum, unit_of_measurement): + def __init__(self, config: Mapping[str, Any], entry_id: str | None = None) -> None: """Initialize the Random sensor.""" - self._name = name - self._minimum = minimum - self._maximum = maximum - self._unit_of_measurement = unit_of_measurement - self._state = None + self._name = config.get(CONF_NAME) + self._minimum = config.get(CONF_MINIMUM, DEFAULT_MIN) + self._maximum = config.get(CONF_MAXIMUM, DEFAULT_MAX) + self._unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + if entry_id: + self._attr_unique_id = entry_id @property def name(self): diff --git a/homeassistant/components/random/strings.json b/homeassistant/components/random/strings.json new file mode 100644 index 00000000000..164f184ae88 --- /dev/null +++ b/homeassistant/components/random/strings.json @@ -0,0 +1,48 @@ +{ + "config": { + "step": { + "binary_sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "name": "[%key:common::config_flow::data::name%]" + }, + "title": "Random binary sensor" + }, + "sensor": { + "data": { + "device_class": "Device class", + "name": "[%key:common::config_flow::data::name%]", + "minimum": "Minimum", + "maximum": "Maximum", + "unit_of_measurement": "Unit of measurement" + }, + "title": "Random sensor" + }, + "user": { + "description": "This helper allow you to create a helper that emits a random value.", + "menu_options": { + "binary_sensor": "Random binary sensor", + "sensor": "Random sensor" + }, + "title": "Random helper" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "title": "[%key:component::random::config::step::binary_sensor::title%]", + "description": "This helper does not have any options." + }, + "sensor": { + "data": { + "device_class": "[%key:component::random::config::step::sensor::data::device_class%]", + "minimum": "[%key:component::random::config::step::sensor::data::minimum%]", + "maximum": "[%key:component::random::config::step::sensor::data::maximum%]", + "unit_of_measurement": "[%key:component::random::config::step::sensor::data::unit_of_measurement%]" + }, + "title": "[%key:component::random::config::step::sensor::title%]" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 99b947d3c52..5cd89432197 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = { "group", "integration", "min_max", + "random", "switch_as_x", "template", "threshold", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ad3d3f6f05a..9d8ac60ee51 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4596,12 +4596,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "random": { - "name": "Random", - "integration_type": "hub", - "config_flow": false, - "iot_class": "local_polling" - }, "rapt_ble": { "name": "RAPT Bluetooth", "integration_type": "hub", @@ -6769,6 +6763,12 @@ "config_flow": true, "iot_class": "calculated" }, + "random": { + "name": "Random", + "integration_type": "helper", + "config_flow": true, + "iot_class": "calculated" + }, "schedule": { "integration_type": "helper", "config_flow": false diff --git a/tests/components/random/test_config_flow.py b/tests/components/random/test_config_flow.py new file mode 100644 index 00000000000..909e866ea92 --- /dev/null +++ b/tests/components/random/test_config_flow.py @@ -0,0 +1,201 @@ +"""Test the Random config flow.""" +from typing import Any +from unittest.mock import patch + +import pytest +from voluptuous import Invalid + +from homeassistant import config_entries +from homeassistant.components.random import async_setup_entry +from homeassistant.components.random.const import DOMAIN +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_input", + "extra_options", + ), + ( + ( + "binary_sensor", + {}, + {}, + ), + ( + "sensor", + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + { + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + "minimum": 0, + "maximum": 20, + }, + ), + ( + "sensor", + {}, + {"minimum": 0, "maximum": 20}, + ), + ), +) +async def test_config_flow( + hass: HomeAssistant, + entity_type: str, + extra_input: dict[str, Any], + extra_options: dict[str, Any], +) -> None: + """Test the config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": entity_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + + with patch( + "homeassistant.components.random.async_setup_entry", wraps=async_setup_entry + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + **extra_input, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "My random entity" + assert result["data"] == {} + assert result["options"] == { + "name": "My random entity", + "entity_type": entity_type, + **extra_options, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("device_class", "unit_of_measurement"), + [ + (SensorDeviceClass.POWER, UnitOfEnergy.WATT_HOUR), + (SensorDeviceClass.ILLUMINANCE, UnitOfEnergy.WATT_HOUR), + ], +) +async def test_wrong_uom( + hass: HomeAssistant, device_class: SensorDeviceClass, unit_of_measurement: str +) -> None: + """Test entering a wrong unit of measurement.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.MENU + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": "sensor"}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "sensor" + + with pytest.raises(Invalid, match="is not a valid unit for device class"): + await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "name": "My random entity", + "device_class": device_class, + "unit_of_measurement": unit_of_measurement, + }, + ) + + +@pytest.mark.parametrize( + ( + "entity_type", + "extra_options", + "options_options", + ), + ( + ( + "sensor", + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "minimum": 0, + "maximum": 20, + }, + { + "minimum": 10, + "maximum": 20, + "device_class": SensorDeviceClass.POWER, + "unit_of_measurement": UnitOfPower.WATT, + }, + ), + ), +) +async def test_options( + hass: HomeAssistant, + entity_type: str, + extra_options, + options_options, +) -> None: + """Test reconfiguring.""" + + random_config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My random", + "entity_type": entity_type, + **extra_options, + }, + title="My random", + ) + random_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(random_config_entry.entry_id) + await hass.async_block_till_done() + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == entity_type + assert "name" not in result["data_schema"].schema + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input=options_options, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.data == {} + assert config_entry.options == { + "name": "My random", + "entity_type": entity_type, + **options_options, + } + assert config_entry.title == "My random" From 8ca5df6fcc9035a19a15cdd0a274c645b1be8b5d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 13:17:41 +0200 Subject: [PATCH 858/968] Guard for None color mode in ZHA (#102774) --- homeassistant/components/zha/light.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 6770ca3b563..6a01d550466 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -850,8 +850,8 @@ class Light(BaseLight, ZhaEntity): self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] - if "color_mode" in last_state.attributes: - self._attr_color_mode = ColorMode(last_state.attributes["color_mode"]) + if (color_mode := last_state.attributes.get("color_mode")) is not None: + self._attr_color_mode = ColorMode(color_mode) if "color_temp" in last_state.attributes: self._attr_color_temp = last_state.attributes["color_temp"] if "xy_color" in last_state.attributes: From 35d18a9a3e9cf0c2a3632e9792fcaf9bde7a362f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Oct 2023 07:20:34 -0400 Subject: [PATCH 859/968] Add tests for types and functions for type conversions in templates (#100807) Co-authored-by: Robert Resch --- homeassistant/helpers/template.py | 42 ++++++++ tests/helpers/test_template.py | 167 ++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 26b0674a351..06280a26ccd 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1956,6 +1956,41 @@ def is_number(value): return True +def _is_list(value: Any) -> bool: + """Return whether a value is a list.""" + return isinstance(value, list) + + +def _is_set(value: Any) -> bool: + """Return whether a value is a set.""" + return isinstance(value, set) + + +def _is_tuple(value: Any) -> bool: + """Return whether a value is a tuple.""" + return isinstance(value, tuple) + + +def _to_set(value: Any) -> set[Any]: + """Convert value to set.""" + return set(value) + + +def _to_tuple(value): + """Convert value to tuple.""" + return tuple(value) + + +def _is_datetime(value: Any) -> bool: + """Return whether a value is a datetime.""" + return isinstance(value, datetime) + + +def _is_string_like(value: Any) -> bool: + """Return whether a value is a string or string like object.""" + return isinstance(value, (str, bytes, bytearray)) + + def regex_match(value, find="", ignorecase=False): """Match value using regex.""" if not isinstance(value, str): @@ -2387,6 +2422,8 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["max"] = min_max_from_filter(self.filters["max"], "max") self.globals["min"] = min_max_from_filter(self.filters["min"], "min") self.globals["is_number"] = is_number + self.globals["set"] = _to_set + self.globals["tuple"] = _to_tuple self.globals["int"] = forgiving_int self.globals["pack"] = struct_pack self.globals["unpack"] = struct_unpack @@ -2395,6 +2432,11 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["bool"] = forgiving_boolean self.globals["version"] = version self.tests["is_number"] = is_number + self.tests["list"] = _is_list + self.tests["set"] = _is_set + self.tests["tuple"] = _is_tuple + self.tests["datetime"] = _is_datetime + self.tests["string_like"] = _is_string_like self.tests["match"] = regex_match self.tests["search"] = regex_search self.tests["contains"] = contains diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 58e0c730165..c466bfed213 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -7,6 +7,7 @@ import json import logging import math import random +from types import MappingProxyType from typing import Any from unittest.mock import patch @@ -43,6 +44,7 @@ from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import TemplateVarsType from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.unit_system import UnitSystem from tests.common import MockConfigEntry, async_fire_time_changed @@ -475,6 +477,171 @@ def test_isnumber(hass: HomeAssistant, value, expected) -> None: ) +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], True), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_list(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is list.""" + assert ( + template.Template("{{ value is list }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, True), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is set.""" + assert ( + template.Template("{{ value is set }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), True), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test is tuple.""" + assert ( + template.Template("{{ value is tuple }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], {1, 2}), + ({1, 2}, {1, 2}), + ({"a": 1, "b": 2}, {"a", "b"}), + (ReadOnlyDict({"a": 1, "b": 2}), {"a", "b"}), + (MappingProxyType({"a": 1, "b": 2}), {"a", "b"}), + ("abc", {"a", "b", "c"}), + (b"abc", {97, 98, 99}), + ((1, 2), {1, 2}), + ], +) +def test_set(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to set function.""" + assert ( + template.Template("{{ set(value) }}", hass).async_render({"value": value}) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], (1, 2)), + ({1, 2}, (1, 2)), + ({"a": 1, "b": 2}, ("a", "b")), + (ReadOnlyDict({"a": 1, "b": 2}), ("a", "b")), + (MappingProxyType({"a": 1, "b": 2}), ("a", "b")), + ("abc", ("a", "b", "c")), + (b"abc", (97, 98, 99)), + ((1, 2), (1, 2)), + ], +) +def test_tuple(hass: HomeAssistant, value: Any, expected: bool) -> None: + """Test convert to tuple function.""" + assert ( + template.Template("{{ tuple(value) }}", hass).async_render({"value": value}) + == expected + ) + + +def test_converting_datetime_to_iterable(hass: HomeAssistant) -> None: + """Test converting a datetime to an iterable raises an error.""" + dt_ = datetime(2020, 1, 1, 0, 0, 0) + with pytest.raises(TemplateError): + template.Template("{{ tuple(value) }}", hass).async_render({"value": dt_}) + with pytest.raises(TemplateError): + template.Template("{{ set(value) }}", hass).async_render({"value": dt_}) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", False), + (b"abc", False), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), True), + ], +) +def test_is_datetime(hass: HomeAssistant, value, expected) -> None: + """Test is datetime.""" + assert ( + template.Template("{{ value is datetime }}", hass).async_render( + {"value": value} + ) + == expected + ) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + ([1, 2], False), + ({1, 2}, False), + ({"a": 1, "b": 2}, False), + (ReadOnlyDict({"a": 1, "b": 2}), False), + (MappingProxyType({"a": 1, "b": 2}), False), + ("abc", True), + (b"abc", True), + ((1, 2), False), + (datetime(2024, 1, 1, 0, 0, 0), False), + ], +) +def test_is_string_like(hass: HomeAssistant, value, expected) -> None: + """Test is string_like.""" + assert ( + template.Template("{{ value is string_like }}", hass).async_render( + {"value": value} + ) + == expected + ) + + def test_rounding_value(hass: HomeAssistant) -> None: """Test rounding value.""" hass.states.async_set("sensor.temperature", 12.78) From 476e867fe838a3fa742dbc36d77de8d2c99807a7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 25 Oct 2023 04:21:10 -0700 Subject: [PATCH 860/968] Add a Local To-do component (#102627) Co-authored-by: Robert Resch Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/local_todo/__init__.py | 55 +++ .../components/local_todo/config_flow.py | 44 ++ homeassistant/components/local_todo/const.py | 6 + .../components/local_todo/manifest.json | 9 + homeassistant/components/local_todo/store.py | 36 ++ .../components/local_todo/strings.json | 16 + homeassistant/components/local_todo/todo.py | 162 ++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + script/hassfest/translations.py | 1 + tests/components/local_todo/__init__.py | 1 + tests/components/local_todo/conftest.py | 104 +++++ .../components/local_todo/test_config_flow.py | 64 +++ tests/components/local_todo/test_init.py | 60 +++ tests/components/local_todo/test_todo.py | 382 ++++++++++++++++++ 20 files changed, 962 insertions(+) create mode 100644 homeassistant/components/local_todo/__init__.py create mode 100644 homeassistant/components/local_todo/config_flow.py create mode 100644 homeassistant/components/local_todo/const.py create mode 100644 homeassistant/components/local_todo/manifest.json create mode 100644 homeassistant/components/local_todo/store.py create mode 100644 homeassistant/components/local_todo/strings.json create mode 100644 homeassistant/components/local_todo/todo.py create mode 100644 tests/components/local_todo/__init__.py create mode 100644 tests/components/local_todo/conftest.py create mode 100644 tests/components/local_todo/test_config_flow.py create mode 100644 tests/components/local_todo/test_init.py create mode 100644 tests/components/local_todo/test_todo.py diff --git a/.strict-typing b/.strict-typing index 97e3f577849..1faf190a1de 100644 --- a/.strict-typing +++ b/.strict-typing @@ -204,6 +204,7 @@ homeassistant.components.light.* homeassistant.components.litejet.* homeassistant.components.litterrobot.* homeassistant.components.local_ip.* +homeassistant.components.local_todo.* homeassistant.components.lock.* homeassistant.components.logbook.* homeassistant.components.logger.* diff --git a/CODEOWNERS b/CODEOWNERS index 6f76291fce8..b9cce3b9047 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -710,6 +710,8 @@ build.json @home-assistant/supervisor /tests/components/local_calendar/ @allenporter /homeassistant/components/local_ip/ @issacg /tests/components/local_ip/ @issacg +/homeassistant/components/local_todo/ @allenporter +/tests/components/local_todo/ @allenporter /homeassistant/components/lock/ @home-assistant/core /tests/components/lock/ @home-assistant/core /homeassistant/components/logbook/ @home-assistant/core diff --git a/homeassistant/components/local_todo/__init__.py b/homeassistant/components/local_todo/__init__.py new file mode 100644 index 00000000000..f8403251ba0 --- /dev/null +++ b/homeassistant/components/local_todo/__init__.py @@ -0,0 +1,55 @@ +"""The Local To-do integration.""" +from __future__ import annotations + +from pathlib import Path + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +PLATFORMS: list[Platform] = [Platform.TODO] + +STORAGE_PATH = ".storage/local_todo.{key}.ics" + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Local To-do from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + + path = Path(hass.config.path(STORAGE_PATH.format(key=entry.data[CONF_STORAGE_KEY]))) + store = LocalTodoListStore(hass, path) + try: + await store.async_load() + except OSError as err: + raise ConfigEntryNotReady("Failed to load file {path}: {err}") from err + + hass.data[DOMAIN][entry.entry_id] = store + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle removal of an entry.""" + key = slugify(entry.data[CONF_TODO_LIST_NAME]) + path = Path(hass.config.path(STORAGE_PATH.format(key=key))) + + def unlink(path: Path) -> None: + path.unlink(missing_ok=True) + + await hass.async_add_executor_job(unlink, path) diff --git a/homeassistant/components/local_todo/config_flow.py b/homeassistant/components/local_todo/config_flow.py new file mode 100644 index 00000000000..73328358a3c --- /dev/null +++ b/homeassistant/components/local_todo/config_flow.py @@ -0,0 +1,44 @@ +"""Config flow for Local To-do integration.""" +from __future__ import annotations + +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.data_entry_flow import FlowResult +from homeassistant.util import slugify + +from .const import CONF_STORAGE_KEY, CONF_TODO_LIST_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TODO_LIST_NAME): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Local To-do.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + key = slugify(user_input[CONF_TODO_LIST_NAME]) + self._async_abort_entries_match({CONF_STORAGE_KEY: key}) + user_input[CONF_STORAGE_KEY] = key + return self.async_create_entry( + title=user_input[CONF_TODO_LIST_NAME], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/local_todo/const.py b/homeassistant/components/local_todo/const.py new file mode 100644 index 00000000000..4677ed42178 --- /dev/null +++ b/homeassistant/components/local_todo/const.py @@ -0,0 +1,6 @@ +"""Constants for the Local To-do integration.""" + +DOMAIN = "local_todo" + +CONF_TODO_LIST_NAME = "todo_list_name" +CONF_STORAGE_KEY = "storage_key" diff --git a/homeassistant/components/local_todo/manifest.json b/homeassistant/components/local_todo/manifest.json new file mode 100644 index 00000000000..049a1824495 --- /dev/null +++ b/homeassistant/components/local_todo/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "local_todo", + "name": "Local To-do", + "codeowners": ["@allenporter"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/local_todo", + "iot_class": "local_polling", + "requirements": ["ical==5.1.0"] +} diff --git a/homeassistant/components/local_todo/store.py b/homeassistant/components/local_todo/store.py new file mode 100644 index 00000000000..79d5adb217f --- /dev/null +++ b/homeassistant/components/local_todo/store.py @@ -0,0 +1,36 @@ +"""Local storage for the Local To-do integration.""" + +import asyncio +from pathlib import Path + +from homeassistant.core import HomeAssistant + + +class LocalTodoListStore: + """Local storage for a single To-do list.""" + + def __init__(self, hass: HomeAssistant, path: Path) -> None: + """Initialize LocalTodoListStore.""" + self._hass = hass + self._path = path + self._lock = asyncio.Lock() + + async def async_load(self) -> str: + """Load the calendar from disk.""" + async with self._lock: + return await self._hass.async_add_executor_job(self._load) + + def _load(self) -> str: + """Load the calendar from disk.""" + if not self._path.exists(): + return "" + return self._path.read_text() + + async def async_store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + async with self._lock: + await self._hass.async_add_executor_job(self._store, ics_content) + + def _store(self, ics_content: str) -> None: + """Persist the calendar to storage.""" + self._path.write_text(ics_content) diff --git a/homeassistant/components/local_todo/strings.json b/homeassistant/components/local_todo/strings.json new file mode 100644 index 00000000000..2403fae60a5 --- /dev/null +++ b/homeassistant/components/local_todo/strings.json @@ -0,0 +1,16 @@ +{ + "title": "Local To-do", + "config": { + "step": { + "user": { + "description": "Please choose a name for your new To-do list", + "data": { + "todo_list_name": "To-do list name" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py new file mode 100644 index 00000000000..14d14316faf --- /dev/null +++ b/homeassistant/components/local_todo/todo.py @@ -0,0 +1,162 @@ +"""A Local To-do todo platform.""" + +from collections.abc import Iterable +import dataclasses +import logging +from typing import Any + +from ical.calendar import Calendar +from ical.calendar_stream import IcsCalendarStream +from ical.store import TodoStore +from ical.todo import Todo, TodoStatus +from pydantic import ValidationError + +from homeassistant.components.todo import ( + TodoItem, + TodoItemStatus, + TodoListEntity, + TodoListEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import CONF_TODO_LIST_NAME, DOMAIN +from .store import LocalTodoListStore + +_LOGGER = logging.getLogger(__name__) + + +PRODID = "-//homeassistant.io//local_todo 1.0//EN" + +ICS_TODO_STATUS_MAP = { + TodoStatus.IN_PROCESS: TodoItemStatus.NEEDS_ACTION, + TodoStatus.NEEDS_ACTION: TodoItemStatus.NEEDS_ACTION, + TodoStatus.COMPLETED: TodoItemStatus.COMPLETED, + TodoStatus.CANCELLED: TodoItemStatus.COMPLETED, +} +ICS_TODO_STATUS_MAP_INV = { + TodoItemStatus.COMPLETED: TodoStatus.COMPLETED, + TodoItemStatus.NEEDS_ACTION: TodoStatus.NEEDS_ACTION, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the local_todo todo platform.""" + + store = hass.data[DOMAIN][config_entry.entry_id] + ics = await store.async_load() + calendar = IcsCalendarStream.calendar_from_ics(ics) + calendar.prodid = PRODID + + name = config_entry.data[CONF_TODO_LIST_NAME] + entity = LocalTodoListEntity(store, calendar, name, unique_id=config_entry.entry_id) + async_add_entities([entity], True) + + +def _todo_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, str]: + """Convert TodoItem dataclass items to dictionary of attributes for ical consumption.""" + result: dict[str, str] = {} + for name, value in obj: + if name == "status": + result[name] = ICS_TODO_STATUS_MAP_INV[value] + elif value is not None: + result[name] = value + return result + + +def _convert_item(item: TodoItem) -> Todo: + """Convert a HomeAssistant TodoItem to an ical Todo.""" + try: + return Todo(**dataclasses.asdict(item, dict_factory=_todo_dict_factory)) + except ValidationError as err: + _LOGGER.debug("Error parsing todo input fields: %s (%s)", item, err) + raise HomeAssistantError("Error parsing todo input fields") from err + + +class LocalTodoListEntity(TodoListEntity): + """A To-do List representation of the Shopping List.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + TodoListEntityFeature.CREATE_TODO_ITEM + | TodoListEntityFeature.DELETE_TODO_ITEM + | TodoListEntityFeature.UPDATE_TODO_ITEM + | TodoListEntityFeature.MOVE_TODO_ITEM + ) + _attr_should_poll = False + + def __init__( + self, + store: LocalTodoListStore, + calendar: Calendar, + name: str, + unique_id: str, + ) -> None: + """Initialize LocalTodoListEntity.""" + self._store = store + self._calendar = calendar + self._attr_name = name.capitalize() + self._attr_unique_id = unique_id + + async def async_update(self) -> None: + """Update entity state based on the local To-do items.""" + self._attr_todo_items = [ + TodoItem( + uid=item.uid, + summary=item.summary or "", + status=ICS_TODO_STATUS_MAP.get( + item.status or TodoStatus.NEEDS_ACTION, TodoItemStatus.NEEDS_ACTION + ), + ) + for item in self._calendar.todos + ] + + async def async_create_todo_item(self, item: TodoItem) -> None: + """Add an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).add(todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_update_todo_item(self, item: TodoItem) -> None: + """Update an item to the To-do list.""" + todo = _convert_item(item) + TodoStore(self._calendar).edit(todo.uid, todo) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_delete_todo_items(self, uids: list[str]) -> None: + """Add an item to the To-do list.""" + store = TodoStore(self._calendar) + for uid in uids: + store.delete(uid) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def async_move_todo_item(self, uid: str, pos: int) -> None: + """Re-order an item to the To-do list.""" + todos = self._calendar.todos + found_item: Todo | None = None + for idx, itm in enumerate(todos): + if itm.uid == uid: + found_item = itm + todos.pop(idx) + break + if found_item is None: + raise HomeAssistantError( + f"Item '{uid}' not found in todo list {self.entity_id}" + ) + todos.insert(pos, found_item) + await self._async_save() + await self.async_update_ha_state(force_refresh=True) + + async def _async_save(self) -> None: + """Persist the todo list to disk.""" + content = IcsCalendarStream.calendar_to_ics(self._calendar) + await self._store.async_store(content) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5cd89432197..48864fef3af 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -264,6 +264,7 @@ FLOWS = { "livisi", "local_calendar", "local_ip", + "local_todo", "locative", "logi_circle", "lookin", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9d8ac60ee51..f834f71bb07 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3111,6 +3111,11 @@ "config_flow": true, "iot_class": "local_polling" }, + "local_todo": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "locative": { "name": "Locative", "integration_type": "hub", @@ -6831,6 +6836,7 @@ "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "min_max", "mobile_app", "moehlenhoff_alpha2", diff --git a/mypy.ini b/mypy.ini index 43ec39ebc56..92b96e75659 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1801,6 +1801,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.local_todo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.lock.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 4b7f69c3c3f..b9171a88e35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1046,6 +1046,7 @@ ibeacon-ble==1.0.1 ibmiotf==0.3.4 # homeassistant.components.local_calendar +# homeassistant.components.local_todo ical==5.1.0 # homeassistant.components.ping diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bc9847f1b8..21bb2f803f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -826,6 +826,7 @@ iaqualink==0.5.0 ibeacon-ble==1.0.1 # homeassistant.components.local_calendar +# homeassistant.components.local_todo ical==5.1.0 # homeassistant.components.ping diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 5c6d7b19719..4483aacd804 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -37,6 +37,7 @@ ALLOW_NAME_TRANSLATION = { "islamic_prayer_times", "local_calendar", "local_ip", + "local_todo", "nmap_tracker", "rpi_power", "waze_travel_time", diff --git a/tests/components/local_todo/__init__.py b/tests/components/local_todo/__init__.py new file mode 100644 index 00000000000..a96a2e85cbd --- /dev/null +++ b/tests/components/local_todo/__init__.py @@ -0,0 +1 @@ +"""Tests for the local_todo integration.""" diff --git a/tests/components/local_todo/conftest.py b/tests/components/local_todo/conftest.py new file mode 100644 index 00000000000..5afa005dd64 --- /dev/null +++ b/tests/components/local_todo/conftest.py @@ -0,0 +1,104 @@ +"""Common fixtures for the local_todo tests.""" +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from homeassistant.components.local_todo import LocalTodoListStore +from homeassistant.components.local_todo.const import ( + CONF_STORAGE_KEY, + CONF_TODO_LIST_NAME, + DOMAIN, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +TODO_NAME = "My Tasks" +FRIENDLY_NAME = "My tasks" +STORAGE_KEY = "my_tasks" +TEST_ENTITY = "todo.my_tasks" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.local_todo.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +class FakeStore(LocalTodoListStore): + """Mock storage implementation.""" + + def __init__( + self, + hass: HomeAssistant, + path: Path, + ics_content: str | None, + read_side_effect: Any | None = None, + ) -> None: + """Initialize FakeStore.""" + mock_path = self._mock_path = Mock() + mock_path.exists = self._mock_exists + mock_path.read_text = Mock() + mock_path.read_text.return_value = ics_content + mock_path.read_text.side_effect = read_side_effect + mock_path.write_text = self._mock_write_text + + super().__init__(hass, mock_path) + + def _mock_exists(self) -> bool: + return self._mock_path.read_text.return_value is not None + + def _mock_write_text(self, content: str) -> None: + self._mock_path.read_text.return_value = content + + +@pytest.fixture(name="ics_content") +def mock_ics_content() -> str | None: + """Fixture to set .ics file content.""" + return "" + + +@pytest.fixture(name="store_read_side_effect") +def mock_store_read_side_effect() -> Any | None: + """Fixture to raise errors from the FakeStore.""" + return None + + +@pytest.fixture(name="store", autouse=True) +def mock_store( + ics_content: str, store_read_side_effect: Any | None +) -> Generator[None, None, None]: + """Fixture that sets up a fake local storage object.""" + + stores: dict[Path, FakeStore] = {} + + def new_store(hass: HomeAssistant, path: Path) -> FakeStore: + if path not in stores: + stores[path] = FakeStore(hass, path, ics_content, store_read_side_effect) + return stores[path] + + with patch("homeassistant.components.local_todo.LocalTodoListStore", new=new_store): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Fixture for mock configuration entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_STORAGE_KEY: STORAGE_KEY, CONF_TODO_LIST_NAME: TODO_NAME}, + ) + + +@pytest.fixture(name="setup_integration") +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Set up the integration.""" + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/local_todo/test_config_flow.py b/tests/components/local_todo/test_config_flow.py new file mode 100644 index 00000000000..6677a39e54a --- /dev/null +++ b/tests/components/local_todo/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test the local_todo config flow.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.local_todo.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import STORAGE_KEY, TODO_NAME + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "todo_list_name": TODO_NAME, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == TODO_NAME + assert result2["data"] == { + "todo_list_name": TODO_NAME, + "storage_key": STORAGE_KEY, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_todo_list_name( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test two todo-lists cannot be added with the same name.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + # Pick a name that has the same slugify value as an existing config entry + "todo_list_name": "my tasks", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.ABORT + assert result2["reason"] == "already_configured" diff --git a/tests/components/local_todo/test_init.py b/tests/components/local_todo/test_init.py new file mode 100644 index 00000000000..98da2ef3c12 --- /dev/null +++ b/tests/components/local_todo/test_init.py @@ -0,0 +1,60 @@ +"""Tests for init platform of local_todo.""" + +from unittest.mock import patch + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.common import MockConfigEntry + + +async def test_load_unload( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test loading and unloading a config entry.""" + + assert config_entry.state == ConfigEntryState.LOADED + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.NOT_LOADED + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "unavailable" + + +async def test_remove_config_entry( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test removing a config entry.""" + + with patch("homeassistant.components.local_todo.Path.unlink") as unlink_mock: + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + unlink_mock.assert_called_once() + + +@pytest.mark.parametrize( + ("store_read_side_effect"), + [ + (OSError("read error")), + ], +) +async def test_load_failure( + hass: HomeAssistant, setup_integration: None, config_entry: MockConfigEntry +) -> None: + """Test failures loading the todo store.""" + + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + state = hass.states.get(TEST_ENTITY) + assert not state diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py new file mode 100644 index 00000000000..6d06649a6ba --- /dev/null +++ b/tests/components/local_todo/test_todo.py @@ -0,0 +1,382 @@ +"""Tests for todo platform of local_todo.""" + +from collections.abc import Awaitable, Callable +import textwrap + +import pytest + +from homeassistant.components.todo import DOMAIN as TODO_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import TEST_ENTITY + +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def ws_req_id() -> Callable[[], int]: + """Fixture for incremental websocket requests.""" + + id = 0 + + def next() -> int: + nonlocal id + id += 1 + return id + + return next + + +@pytest.fixture +async def ws_get_items( + hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int] +) -> Callable[[], Awaitable[dict[str, str]]]: + """Fixture to fetch items from the todo websocket.""" + + async def get() -> list[dict[str, str]]: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + await client.send_json( + { + "id": id, + "type": "todo/item/list", + "entity_id": TEST_ENTITY, + } + ) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + return resp.get("result", {}).get("items", []) + + return get + + +@pytest.fixture +async def ws_move_item( + hass_ws_client: WebSocketGenerator, + ws_req_id: Callable[[], int], +) -> Callable[[str, str | None], Awaitable[None]]: + """Fixture to move an item in the todo list.""" + + async def move(uid: str, pos: int) -> None: + # Fetch items using To-do platform + client = await hass_ws_client() + id = ws_req_id() + data = { + "id": id, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": uid, + "pos": pos, + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == id + assert resp.get("success") + + return move + + +async def test_create_item( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test creating a todo item.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + +async def test_delete_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting a todo item.""" + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "replace batteries"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 1 + assert items[0]["summary"] == "replace batteries" + assert items[0]["status"] == "needs_action" + assert "uid" in items[0] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": [items[0]["uid"]]}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_bulk_delete( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test deleting multiple todo items.""" + for i in range(0, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": f"soda #{i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 5 + uids = [item["uid"] for item in items] + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "5" + + await hass.services.async_call( + TODO_DOMAIN, + "delete_item", + {"uid": uids}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 0 + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +async def test_update_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test updating a todo item.""" + + # Create new item + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "soda"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Fetch item + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "needs_action" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "1" + + # Mark item completed + await hass.services.async_call( + TODO_DOMAIN, + "update_item", + {"uid": item["uid"], "status": "completed"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + # Verify item is marked as completed + items = await ws_get_items() + assert len(items) == 1 + item = items[0] + assert item["summary"] == "soda" + assert item["status"] == "completed" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == "0" + + +@pytest.mark.parametrize( + ("src_idx", "pos", "expected_items"), + [ + # Move any item to the front of the list + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (1, 0, ["item 2", "item 1", "item 3", "item 4"]), + (2, 0, ["item 3", "item 1", "item 2", "item 4"]), + (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + # Move items right + (0, 1, ["item 2", "item 1", "item 3", "item 4"]), + (0, 2, ["item 2", "item 3", "item 1", "item 4"]), + (0, 3, ["item 2", "item 3", "item 4", "item 1"]), + (1, 2, ["item 1", "item 3", "item 2", "item 4"]), + (1, 3, ["item 1", "item 3", "item 4", "item 2"]), + (1, 4, ["item 1", "item 3", "item 4", "item 2"]), + (1, 5, ["item 1", "item 3", "item 4", "item 2"]), + # Move items left + (2, 1, ["item 1", "item 3", "item 2", "item 4"]), + (3, 1, ["item 1", "item 4", "item 2", "item 3"]), + (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + # No-ops + (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 3, ["item 1", "item 2", "item 3", "item 4"]), + (3, 4, ["item 1", "item 2", "item 3", "item 4"]), + ], +) +async def test_move_item( + hass: HomeAssistant, + setup_integration: None, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], + ws_move_item: Callable[[str, str | None], Awaitable[None]], + src_idx: int, + pos: int, + expected_items: list[str], +) -> None: + """Test moving a todo item within the list.""" + for i in range(1, 5): + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": f"item {i}"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + + items = await ws_get_items() + assert len(items) == 4 + uids = [item["uid"] for item in items] + summaries = [item["summary"] for item in items] + assert summaries == ["item 1", "item 2", "item 3", "item 4"] + + # Prepare items for moving + await ws_move_item(uids[src_idx], pos) + + items = await ws_get_items() + assert len(items) == 4 + summaries = [item["summary"] for item in items] + assert summaries == expected_items + + +async def test_move_item_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test moving a todo item that does not exist.""" + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": "unknown", + "pos": 0, + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +@pytest.mark.parametrize( + ("ics_content", "expected_state"), + [ + ("", "0"), + (None, "0"), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:COMPLETED + SUMMARY:Complete Task + END:VTODO + END:VCALENDAR + """ + ), + "0", + ), + ( + textwrap.dedent( + """\ + BEGIN:VCALENDAR + PRODID:-//homeassistant.io//local_todo 1.0//EN + VERSION:2.0 + BEGIN:VTODO + DTSTAMP:20231024T014011 + UID:077cb7f2-6c89-11ee-b2a9-0242ac110002 + CREATED:20231017T010348 + LAST-MODIFIED:20231024T014011 + SEQUENCE:1 + STATUS:NEEDS-ACTION + SUMMARY:Incomplete Task + END:VTODO + END:VCALENDAR + """ + ), + "1", + ), + ], + ids=("empty", "not_exists", "completed", "needs_action"), +) +async def test_parse_existing_ics( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + setup_integration: None, + expected_state: str, +) -> None: + """Test parsing ics content.""" + + state = hass.states.get(TEST_ENTITY) + assert state + assert state.state == expected_state From 47c9d58b5e750fe1034c53b9dc3621df7c9667e7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 25 Oct 2023 11:48:00 +0000 Subject: [PATCH 861/968] Override the `async_update()` method for Shelly sleeping devices (#102516) --- homeassistant/components/shelly/entity.py | 14 ++++ tests/components/shelly/test_sensor.py | 80 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 92100eaddaf..368a997c62e 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -618,6 +618,13 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): super()._update_callback() return + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) + class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): """Helper class to represent a sleeping rpc attribute.""" @@ -654,3 +661,10 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): ) elif entry is not None: self._attr_name = cast(str, entry.original_name) + + async def async_update(self) -> None: + """Update the entity.""" + LOGGER.info( + "Entity %s comes from a sleeping device, update is not possible", + self.entity_id, + ) diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index a738113f18f..380f4f5999e 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -1,9 +1,15 @@ """Tests for Shelly sensor platform.""" from freezegun.api import FrozenDateTimeFactory +import pytest +from homeassistant.components.homeassistant import ( + DOMAIN as HA_DOMAIN, + SERVICE_UPDATE_ENTITY, +) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.shelly.const import DOMAIN from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, @@ -12,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers.entity_registry import async_get +from homeassistant.setup import async_setup_component from . import ( init_integration, @@ -448,3 +455,76 @@ async def test_rpc_em1_sensors( entry = registry.async_get("sensor.test_name_em1_total_active_energy") assert entry assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" + + +async def test_rpc_sleeping_update_entity_service( + hass: HomeAssistant, mock_rpc_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test RPC sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 2, sleep_period=1000) + + # Entity should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_rpc_device.mock_update() + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "22.9" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + 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) + assert state.state == "22.9" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) + + +async def test_block_sleeping_update_entity_service( + hass: HomeAssistant, mock_block_device, caplog: pytest.LogCaptureFixture +) -> None: + """Test block sleeping device when the update_entity service is used.""" + await async_setup_component(hass, "homeassistant", {}) + + entity_id = f"{SENSOR_DOMAIN}.test_name_temperature" + await init_integration(hass, 1, sleep_period=1000) + + # Sensor should be created when device is online + assert hass.states.get(entity_id) is None + + # Make device online + mock_block_device.mock_update() + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "22.1" + + await hass.services.async_call( + HA_DOMAIN, + SERVICE_UPDATE_ENTITY, + 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) + assert state.state == "22.1" + + assert ( + "Entity sensor.test_name_temperature comes from a sleeping device" + in caplog.text + ) From edc9aba722aa03342c48fac5bc5a6b5211b21144 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 25 Oct 2023 14:01:36 +0200 Subject: [PATCH 862/968] Update frontend to 20231025.0 (#102776) --- 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 0d1c1659471..73720f7ef83 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==20231005.0"] + "requirements": ["home-assistant-frontend==20231025.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 06bfeabb9e4..ce2ed867074 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231025.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b9171a88e35..7624b47e268 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231025.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 21bb2f803f5..c03e1d56d66 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231005.0 +home-assistant-frontend==20231025.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 2c46a975fb1d4eeb88d1e5104a00bec3e43877d8 Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Wed, 25 Oct 2023 14:02:30 +0200 Subject: [PATCH 863/968] Add re-authentication to Jellyfin (#97442) --- homeassistant/components/jellyfin/__init__.py | 9 +- .../components/jellyfin/config_flow.py | 46 +++ .../components/jellyfin/strings.json | 10 +- tests/components/jellyfin/const.py | 12 + tests/components/jellyfin/test_config_flow.py | 287 +++++++++++++++--- tests/components/jellyfin/test_init.py | 6 +- 6 files changed, 328 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/jellyfin/__init__.py b/homeassistant/components/jellyfin/__init__.py index f25c3410edb..2e9e6bb71f7 100644 --- a/homeassistant/components/jellyfin/__init__.py +++ b/homeassistant/components/jellyfin/__init__.py @@ -3,11 +3,11 @@ from typing import Any from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from .client_wrapper import CannotConnect, InvalidAuth, create_client, validate_input -from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, LOGGER, PLATFORMS +from .const import CONF_CLIENT_DEVICE_ID, DOMAIN, PLATFORMS from .coordinator import JellyfinDataUpdateCoordinator, SessionsDataUpdateCoordinator from .models import JellyfinData @@ -30,9 +30,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: user_id, connect_result = await validate_input(hass, dict(entry.data), client) except CannotConnect as ex: raise ConfigEntryNotReady("Cannot connect to Jellyfin server") from ex - except InvalidAuth: - LOGGER.error("Failed to login to Jellyfin server") - return False + except InvalidAuth as ex: + raise ConfigEntryAuthFailed(ex) from ex server_info: dict[str, Any] = connect_result["Servers"][0] diff --git a/homeassistant/components/jellyfin/config_flow.py b/homeassistant/components/jellyfin/config_flow.py index 84b78d51926..84360ed053e 100644 --- a/homeassistant/components/jellyfin/config_flow.py +++ b/homeassistant/components/jellyfin/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Jellyfin integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,6 +25,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema( } ) +REAUTH_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_PASSWORD, default=""): str, + } +) + def _generate_client_device_id() -> str: """Generate a random UUID4 string to identify ourselves.""" @@ -38,6 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Jellyfin config flow.""" self.client_device_id: str | None = None + self.entry: config_entries.ConfigEntry | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -83,3 +91,41 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + errors: dict[str, str] = {} + + if user_input is not None: + assert self.entry is not None + new_input = self.entry.data | user_input + + if self.client_device_id is None: + self.client_device_id = _generate_client_device_id() + + client = create_client(device_id=self.client_device_id) + try: + await validate_input(self.hass, new_input, client) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception as ex: # pylint: disable=broad-except + errors["base"] = "unknown" + _LOGGER.exception(ex) + else: + self.hass.config_entries.async_update_entry(self.entry, data=new_input) + + await self.hass.config_entries.async_reload(self.entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", data_schema=REAUTH_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/jellyfin/strings.json b/homeassistant/components/jellyfin/strings.json index 8d74d416a94..3e8965da785 100644 --- a/homeassistant/components/jellyfin/strings.json +++ b/homeassistant/components/jellyfin/strings.json @@ -1,6 +1,13 @@ { "config": { "step": { + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "The Jellyfin integration needs to re-authenticate your account", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", @@ -16,7 +23,8 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/tests/components/jellyfin/const.py b/tests/components/jellyfin/const.py index 4953824a1c5..157c25b4af4 100644 --- a/tests/components/jellyfin/const.py +++ b/tests/components/jellyfin/const.py @@ -2,6 +2,18 @@ from typing import Final +from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME + TEST_URL: Final = "https://example.com" TEST_USERNAME: Final = "test-username" TEST_PASSWORD: Final = "test-password" + +USER_INPUT: Final = { + CONF_URL: TEST_URL, + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, +} + +REAUTH_INPUT: Final = { + CONF_PASSWORD: TEST_PASSWORD, +} diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index 51aa4bccc92..c59efd7efb9 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant from . import async_load_json_fixture -from .const import TEST_PASSWORD, TEST_URL, TEST_USERNAME +from .const import REAUTH_INPUT, TEST_PASSWORD, TEST_URL, TEST_USERNAME, USER_INPUT from tests.common import MockConfigEntry @@ -44,11 +44,7 @@ async def test_form( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -73,7 +69,7 @@ async def test_form_cannot_connect( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test we handle an unreachable server.""" + """Test configuration with an unreachable server.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -86,11 +82,7 @@ async def test_form_cannot_connect( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -106,7 +98,7 @@ async def test_form_invalid_auth( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test configuration with invalid credentials.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -119,11 +111,7 @@ async def test_form_invalid_auth( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_form_invalid_auth( async def test_form_exception( hass: HomeAssistant, mock_jellyfin: MagicMock, mock_client: MagicMock ) -> None: - """Test we handle an unexpected exception during server setup.""" + """Test configuration with an unexpected exception.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -148,11 +136,7 @@ async def test_form_exception( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -168,7 +152,7 @@ async def test_form_persists_device_id_on_error( mock_client: MagicMock, mock_client_device_id: MagicMock, ) -> None: - """Test that we can handle invalid credentials.""" + """Test persisting the device id on error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -182,11 +166,7 @@ async def test_form_persists_device_id_on_error( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -200,11 +180,7 @@ async def test_form_persists_device_id_on_error( result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], - { - CONF_URL: TEST_URL, - CONF_USERNAME: TEST_USERNAME, - CONF_PASSWORD: TEST_PASSWORD, - }, + user_input=USER_INPUT, ) await hass.async_block_till_done() @@ -216,3 +192,244 @@ async def test_form_persists_device_id_on_error( CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, } + + +async def test_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Complete the reauth + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unreachable server during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with unreachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address-failure.json" + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete reauth with reachable server + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, "auth-connect-address.json" + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_invalid( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test invalid credentials during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform reauth with invalid credentials + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + assert len(mock_client.auth.login.mock_calls) == 1 + + # Complete reauth with valid credentials + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" + + +async def test_reauth_exception( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test an unexpected exception during a reauth flow.""" + # Force a reauth + mock_client.auth.connect_to_address.return_value = await async_load_json_fixture( + hass, + "auth-connect-address.json", + ) + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login-failure.json", + ) + + mock_config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + # Perform a reauth with an unknown exception + mock_client.auth.connect_to_address.side_effect = Exception("UnknownException") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + await hass.async_block_till_done() + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"} + + assert len(mock_client.auth.connect_to_address.mock_calls) == 1 + + # Complete the reauth without an exception + mock_client.auth.login.return_value = await async_load_json_fixture( + hass, + "auth-login.json", + ) + mock_client.auth.connect_to_address.side_effect = None + + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=REAUTH_INPUT, + ) + assert result3["type"] == data_entry_flow.FlowResultType.ABORT + assert result3["reason"] == "reauth_successful" diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 9af73391d18..eb184592bb8 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock from homeassistant.components.jellyfin.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -67,6 +67,10 @@ async def test_invalid_auth( mock_config_entry.add_to_hass(hass) assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["source"] == SOURCE_REAUTH + async def test_load_unload_config_entry( hass: HomeAssistant, From b50f5e50c3d90b4849edc2232860622b2f1a1443 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 25 Oct 2023 14:42:44 +0200 Subject: [PATCH 864/968] Update frontend to 20231025.1 (#102781) --- 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 73720f7ef83..064777b4921 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==20231025.0"] + "requirements": ["home-assistant-frontend==20231025.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ce2ed867074..def5f0c9afa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231025.0 +home-assistant-frontend==20231025.1 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7624b47e268..f65ef0f33a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.0 +home-assistant-frontend==20231025.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c03e1d56d66..51bcc8562e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.0 +home-assistant-frontend==20231025.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From e4a1efb6800a63a536cda0607c9e05f7e1759bf4 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 25 Oct 2023 14:48:33 +0200 Subject: [PATCH 865/968] Fix Comelit comments as per late review (#102783) --- homeassistant/components/comelit/cover.py | 3 ++- homeassistant/components/comelit/light.py | 3 ++- homeassistant/components/comelit/sensor.py | 3 ++- homeassistant/components/comelit/switch.py | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/comelit/cover.py b/homeassistant/components/comelit/cover.py index 61b0cb39061..4a3c8eed63c 100644 --- a/homeassistant/components/comelit/cover.py +++ b/homeassistant/components/comelit/cover.py @@ -51,7 +51,8 @@ class ComelitCoverEntity( self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) # Device doesn't provide a status so we assume UNKNOWN at first startup diff --git a/homeassistant/components/comelit/light.py b/homeassistant/components/comelit/light.py index 258dc2ce1e7..95906f7ec6e 100644 --- a/homeassistant/components/comelit/light.py +++ b/homeassistant/components/comelit/light.py @@ -47,7 +47,8 @@ class ComelitLightEntity(CoordinatorEntity[ComelitSerialBridge], LightEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) diff --git a/homeassistant/components/comelit/sensor.py b/homeassistant/components/comelit/sensor.py index 5cbc708d63e..554433fa6ad 100644 --- a/homeassistant/components/comelit/sensor.py +++ b/homeassistant/components/comelit/sensor.py @@ -66,7 +66,8 @@ class ComelitSensorEntity(CoordinatorEntity[ComelitSerialBridge], SensorEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) diff --git a/homeassistant/components/comelit/switch.py b/homeassistant/components/comelit/switch.py index 46cbb74fce7..379b936c3bb 100644 --- a/homeassistant/components/comelit/switch.py +++ b/homeassistant/components/comelit/switch.py @@ -53,7 +53,8 @@ class ComelitSwitchEntity(CoordinatorEntity[ComelitSerialBridge], SwitchEntity): self._api = coordinator.api self._device = device super().__init__(coordinator) - # Use config_entry.entry_id as base for unique_id because no serial number or mac is available + # Use config_entry.entry_id as base for unique_id + # because no serial number or mac is available self._attr_unique_id = f"{config_entry_entry_id}-{device.type}-{device.index}" self._attr_device_info = coordinator.platform_device_info(device) if device.type == OTHER: From 6e72499f96742184105911a53e4454c2b6525450 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 15:13:38 +0200 Subject: [PATCH 866/968] Use real devices in nest device trigger tests (#102692) --- tests/components/nest/test_device_trigger.py | 164 +++++++++++++++++-- 1 file changed, 147 insertions(+), 17 deletions(-) diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index 852075c6527..181ad313c38 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -230,53 +230,164 @@ async def test_no_triggers( assert triggers == [] -async def test_fires_on_camera_motion(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_motion( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_motion triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_motion", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") + + message = { + "device_id": device_entry.id, + "type": "camera_motion", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_person(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_person( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_person") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_person", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_person") + + message = { + "device_id": device_entry.id, + "type": "camera_person", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_camera_sound(hass: HomeAssistant, calls) -> None: +async def test_fires_on_camera_sound( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test camera_person triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_sound") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "camera_sound", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_sound") + + message = { + "device_id": device_entry.id, + "type": "camera_sound", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_fires_on_doorbell_chime(hass: HomeAssistant, calls) -> None: +async def test_fires_on_doorbell_chime( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test doorbell_chime triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "doorbell_chime") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() - message = {"device_id": DEVICE_ID, "type": "doorbell_chime", "timestamp": utcnow()} + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "doorbell_chime") + + message = { + "device_id": device_entry.id, + "type": "doorbell_chime", + "timestamp": utcnow(), + } hass.bus.async_fire(NEST_EVENT, message) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data == DATA_MESSAGE -async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: +async def test_trigger_for_wrong_device_id( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { "device_id": "wrong-device-id", @@ -288,12 +399,31 @@ async def test_trigger_for_wrong_device_id(hass: HomeAssistant, calls) -> None: assert len(calls) == 0 -async def test_trigger_for_wrong_event_type(hass: HomeAssistant, calls) -> None: +async def test_trigger_for_wrong_event_type( + hass: HomeAssistant, + create_device: CreateDevice, + setup_platform: PlatformSetup, + calls, +) -> None: """Test for turn_on and turn_off triggers firing.""" - assert await setup_automation(hass, DEVICE_ID, "camera_motion") + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) + ) + await setup_platform() + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get_device(identifiers={("nest", DEVICE_ID)}) + + assert await setup_automation(hass, device_entry.id, "camera_motion") message = { - "device_id": DEVICE_ID, + "device_id": device_entry.id, "type": "wrong-event-type", "timestamp": utcnow(), } From 4447336083e7a05ed5718163472eeaf0b3db1952 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 08:32:43 -0500 Subject: [PATCH 867/968] Fix hassio delaying startup to fetch container stats (#102775) --- homeassistant/components/hassio/__init__.py | 33 ++++-- homeassistant/components/hassio/const.py | 2 + homeassistant/components/hassio/entity.py | 7 ++ tests/components/hassio/test_init.py | 122 +++++++++++++++++--- tests/components/hassio/test_sensor.py | 13 +++ tests/components/hassio/test_update.py | 15 ++- 6 files changed, 162 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 78e9c40cebd..e7ab7aac3c8 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -34,6 +34,7 @@ from homeassistant.core import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.event import async_call_later from homeassistant.helpers.storage import Store @@ -74,6 +75,7 @@ from .const import ( DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DOMAIN, + REQUEST_REFRESH_DELAY, SUPERVISOR_CONTAINER, SupervisorEntityModel, ) @@ -334,7 +336,7 @@ def get_addons_stats(hass): Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) + return hass.data.get(DATA_ADDONS_STATS) or {} @callback @@ -344,7 +346,7 @@ def get_core_stats(hass): Async friendly. """ - return hass.data.get(DATA_CORE_STATS) + return hass.data.get(DATA_CORE_STATS) or {} @callback @@ -354,7 +356,7 @@ def get_supervisor_stats(hass): Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) + return hass.data.get(DATA_SUPERVISOR_STATS) or {} @callback @@ -754,6 +756,12 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER, name=DOMAIN, update_interval=HASSIO_UPDATE_INTERVAL, + # We don't want an immediate refresh since we want to avoid + # fetching the container stats right away and avoid hammering + # the Supervisor API on startup + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), ) self.hassio: HassIO = hass.data[DOMAIN] self.data = {} @@ -875,9 +883,9 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): DATA_SUPERVISOR_INFO: hassio.get_supervisor_info(), DATA_OS_INFO: hassio.get_os_info(), } - if first_update or CONTAINER_STATS in container_updates[CORE_CONTAINER]: + if CONTAINER_STATS in container_updates[CORE_CONTAINER]: updates[DATA_CORE_STATS] = hassio.get_core_stats() - if first_update or CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: + if CONTAINER_STATS in container_updates[SUPERVISOR_CONTAINER]: updates[DATA_SUPERVISOR_STATS] = hassio.get_supervisor_stats() results = await asyncio.gather(*updates.values()) @@ -903,20 +911,28 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): # API calls since otherwise we would fetch stats for all containers # and throw them away. # - for data_key, update_func, enabled_key, wanted_addons in ( + for data_key, update_func, enabled_key, wanted_addons, needs_first_update in ( ( DATA_ADDONS_STATS, self._update_addon_stats, CONTAINER_STATS, started_addons, + False, ), ( DATA_ADDONS_CHANGELOGS, self._update_addon_changelog, CONTAINER_CHANGELOG, all_addons, + True, + ), + ( + DATA_ADDONS_INFO, + self._update_addon_info, + CONTAINER_INFO, + all_addons, + True, ), - (DATA_ADDONS_INFO, self._update_addon_info, CONTAINER_INFO, all_addons), ): container_data: dict[str, Any] = data.setdefault(data_key, {}) container_data.update( @@ -925,7 +941,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): *[ update_func(slug) for slug in wanted_addons - if first_update or enabled_key in container_updates[slug] + if (first_update and needs_first_update) + or enabled_key in container_updates[slug] ] ) ) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 193d4762c5a..b495745e87d 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -101,6 +101,8 @@ KEY_TO_UPDATE_TYPES: dict[str, set[str]] = { ATTR_STATE: {CONTAINER_INFO}, } +REQUEST_REFRESH_DELAY = 10 + class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index 16e418d91d5..63e0314dd05 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -10,6 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import DOMAIN, HassioDataUpdateCoordinator from .const import ( ATTR_SLUG, + CONTAINER_STATS, CORE_CONTAINER, DATA_KEY_ADDONS, DATA_KEY_CORE, @@ -58,6 +59,8 @@ class HassioAddonEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): self._addon_slug, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioOSEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -147,6 +150,8 @@ class HassioSupervisorEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): SUPERVISOR_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): @@ -183,3 +188,5 @@ class HassioCoreEntity(CoordinatorEntity[HassioDataUpdateCoordinator]): CORE_CONTAINER, self.entity_id, update_types ) ) + if CONTAINER_STATS in update_types: + await self.coordinator.async_request_refresh() diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 99e1de6e763..4bf3e29154e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,6 +17,7 @@ from homeassistant.components.hassio import ( async_get_addon_store_info, hostname_from_addon_slug, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.components.hassio.handler import HassioAPIError from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant @@ -244,7 +245,7 @@ async def test_setup_api_ping( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert hass.components.hassio.get_core_info()["version_latest"] == "1.0.0" assert hass.components.hassio.is_hassio() @@ -289,7 +290,7 @@ async def test_setup_api_push_api_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["watchdog"] @@ -308,7 +309,7 @@ async def test_setup_api_push_api_data_server_host( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert not aioclient_mock.mock_calls[1][2]["watchdog"] @@ -325,7 +326,7 @@ async def test_setup_api_push_api_data_default( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 refresh_token = aioclient_mock.mock_calls[1][2]["refresh_token"] @@ -405,7 +406,7 @@ async def test_setup_api_existing_hassio_user( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert not aioclient_mock.mock_calls[1][2]["ssl"] assert aioclient_mock.mock_calls[1][2]["port"] == 8123 assert aioclient_mock.mock_calls[1][2]["refresh_token"] == token.token @@ -422,7 +423,7 @@ async def test_setup_core_push_timezone( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[2][2]["timezone"] == "testzone" with patch("homeassistant.util.dt.set_default_time_zone"): @@ -442,7 +443,7 @@ async def test_setup_hassio_no_additional_data( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" @@ -524,14 +525,14 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 26 + assert aioclient_mock.call_count == 24 assert aioclient_mock.mock_calls[-1][2] == "test" await hass.services.async_call("hassio", "host_shutdown", {}) await hass.services.async_call("hassio", "host_reboot", {}) await hass.async_block_till_done() - assert aioclient_mock.call_count == 28 + assert aioclient_mock.call_count == 26 await hass.services.async_call("hassio", "backup_full", {}) await hass.services.async_call( @@ -546,7 +547,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 30 + assert aioclient_mock.call_count == 28 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "homeassistant": True, @@ -571,7 +572,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 32 + assert aioclient_mock.call_count == 30 assert aioclient_mock.mock_calls[-1][2] == { "addons": ["test"], "folders": ["ssl"], @@ -590,7 +591,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 33 + assert aioclient_mock.call_count == 31 assert aioclient_mock.mock_calls[-1][2] == { "name": "backup_name", "location": "backup_share", @@ -606,7 +607,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 34 + assert aioclient_mock.call_count == 32 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 03:48:00", "location": None, @@ -624,7 +625,7 @@ async def test_service_calls( ) await hass.async_block_till_done() - assert aioclient_mock.call_count == 36 + assert aioclient_mock.call_count == 34 assert aioclient_mock.mock_calls[-1][2] == { "name": "2021-11-13 11:48:00", "location": None, @@ -896,6 +897,7 @@ async def test_coordinator_updates( config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + # Initial refresh without stats assert refresh_updates_mock.call_count == 1 with patch( @@ -919,10 +921,12 @@ async def test_coordinator_updates( }, blocking=True, ) - assert refresh_updates_mock.call_count == 1 + assert refresh_updates_mock.call_count == 0 - # There is a 10s cooldown on the debouncer - async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=10)) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) await hass.async_block_till_done() with patch( @@ -940,6 +944,88 @@ async def test_coordinator_updates( }, blocking=True, ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 1 + assert "Error on Supervisor API: Unknown" in caplog.text + + +async def test_coordinator_updates_stats_entities_enabled( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + entity_registry_enabled_by_default: None, +) -> None: + """Test coordinator updates with stats entities enabled.""" + await async_setup_component(hass, "homeassistant", {}) + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.refresh_updates" + ) as refresh_updates_mock: + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Initial refresh without stats + assert refresh_updates_mock.call_count == 1 + + # Refresh with stats once we know which ones are needed + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 2 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20)) + await hass.async_block_till_done() + assert refresh_updates_mock.call_count == 0 + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + assert refresh_updates_mock.call_count == 0 + + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.hassio.HassIO.refresh_updates", + side_effect=HassioAPIError("Unknown"), + ) as refresh_updates_mock: + await hass.services.async_call( + "homeassistant", + "update_entity", + { + "entity_id": [ + "update.home_assistant_core_update", + "update.home_assistant_supervisor_update", + ] + }, + blocking=True, + ) + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() assert refresh_updates_mock.call_count == 1 assert "Error on Supervisor API: Unknown" in caplog.text @@ -973,7 +1059,7 @@ async def test_setup_hardware_integration( await hass.async_block_till_done() assert result - assert aioclient_mock.call_count == 22 + assert aioclient_mock.call_count == 20 assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 817bf871fef..fbc6f08a1f5 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -10,6 +10,7 @@ from homeassistant.components.hassio import ( HASSIO_UPDATE_INTERVAL, HassioAPIError, ) +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component @@ -245,6 +246,12 @@ async def test_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected @@ -306,6 +313,12 @@ async def test_stats_addon_sensor( await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the entity have the expected state. state = hass.states.get(entity_id) assert state.state == expected diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 3f12874ef52..42918b02266 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -1,16 +1,18 @@ """The tests for the hassio update entities.""" +from datetime import timedelta import os from unittest.mock import patch import pytest -from homeassistant.components.hassio import DOMAIN -from homeassistant.components.hassio.handler import HassioAPIError +from homeassistant.components.hassio import DOMAIN, HassioAPIError +from homeassistant.components.hassio.const import REQUEST_REFRESH_DELAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import WebSocketGenerator @@ -609,8 +611,13 @@ async def test_setting_up_core_update_when_addon_fails( await hass.async_block_till_done() assert result + # There is a REQUEST_REFRESH_DELAYs cooldown on the debouncer + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + # Verify that the core update entity does exist state = hass.states.get("update.home_assistant_core_update") assert state assert state.state == "on" - assert "Could not fetch stats for test: add-on is not running" in caplog.text From 89e2f063040ff0cb4a67a7baa778d3f2e3a2edfa Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 25 Oct 2023 06:34:48 -0700 Subject: [PATCH 868/968] Flume: Add flume.notifications service (#100621) Co-authored-by: Franck Nijhof Co-authored-by: Robert Resch --- homeassistant/components/flume/__init__.py | 50 ++++++++++++++++++- .../components/flume/binary_sensor.py | 7 +-- homeassistant/components/flume/const.py | 2 +- homeassistant/components/flume/services.yaml | 7 +++ homeassistant/components/flume/strings.json | 12 +++++ 5 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/flume/services.yaml diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index 294f50c50e2..9a96233e6a9 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -2,6 +2,7 @@ from pyflume import FlumeAuth, FlumeDeviceList from requests import Session from requests.exceptions import RequestException +import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -10,8 +11,14 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + ServiceResponse, + SupportsResponse, +) from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.selector import ConfigEntrySelector from .const import ( BASE_TOKEN_FILENAME, @@ -19,8 +26,18 @@ from .const import ( FLUME_AUTH, FLUME_DEVICES, FLUME_HTTP_SESSION, + FLUME_NOTIFICATIONS_COORDINATOR, PLATFORMS, ) +from .coordinator import FlumeNotificationDataUpdateCoordinator + +SERVICE_LIST_NOTIFICATIONS = "list_notifications" +CONF_CONFIG_ENTRY = "config_entry" +LIST_NOTIFICATIONS_SERVICE_SCHEMA = vol.All( + { + vol.Required(CONF_CONFIG_ENTRY): ConfigEntrySelector(), + }, +) def _setup_entry(hass: HomeAssistant, entry: ConfigEntry): @@ -59,14 +76,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: flume_auth, flume_devices, http_session = await hass.async_add_executor_job( _setup_entry, hass, entry ) + notification_coordinator = FlumeNotificationDataUpdateCoordinator( + hass=hass, auth=flume_auth + ) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { FLUME_DEVICES: flume_devices, FLUME_AUTH: flume_auth, FLUME_HTTP_SESSION: http_session, + FLUME_NOTIFICATIONS_COORDINATOR: notification_coordinator, } await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_setup_service(hass) return True @@ -81,3 +103,29 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_setup_service(hass: HomeAssistant) -> None: + """Add the services for the flume integration.""" + + async def list_notifications(call: ServiceCall) -> ServiceResponse: + """Return the user notifications.""" + entry_id: str = call.data[CONF_CONFIG_ENTRY] + entry: ConfigEntry | None = hass.config_entries.async_get_entry(entry_id) + if not entry: + raise ValueError(f"Invalid config entry: {entry_id}") + if not (flume_domain_data := hass.data[DOMAIN].get(entry_id)): + raise ValueError(f"Config entry not loaded: {entry_id}") + return { + "notifications": flume_domain_data[ + FLUME_NOTIFICATIONS_COORDINATOR + ].notifications + } + + hass.services.async_register( + DOMAIN, + SERVICE_LIST_NOTIFICATIONS, + list_notifications, + schema=LIST_NOTIFICATIONS_SERVICE_SCHEMA, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/flume/binary_sensor.py b/homeassistant/components/flume/binary_sensor.py index c912c3419d7..2305cd9f23e 100644 --- a/homeassistant/components/flume/binary_sensor.py +++ b/homeassistant/components/flume/binary_sensor.py @@ -15,8 +15,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DOMAIN, - FLUME_AUTH, FLUME_DEVICES, + FLUME_NOTIFICATIONS_COORDINATOR, FLUME_TYPE_BRIDGE, FLUME_TYPE_SENSOR, KEY_DEVICE_ID, @@ -84,7 +84,6 @@ async def async_setup_entry( ) -> None: """Set up a Flume binary sensor..""" flume_domain_data = hass.data[DOMAIN][config_entry.entry_id] - flume_auth = flume_domain_data[FLUME_AUTH] flume_devices = flume_domain_data[FLUME_DEVICES] flume_entity_list: list[ @@ -94,9 +93,7 @@ async def async_setup_entry( connection_coordinator = FlumeDeviceConnectionUpdateCoordinator( hass=hass, flume_devices=flume_devices ) - notification_coordinator = FlumeNotificationDataUpdateCoordinator( - hass=hass, auth=flume_auth - ) + notification_coordinator = flume_domain_data[FLUME_NOTIFICATIONS_COORDINATOR] flume_devices = get_valid_flume_devices(flume_devices) for device in flume_devices: device_id = device[KEY_DEVICE_ID] diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py index 9e932cce4dd..a4e7dba444e 100644 --- a/homeassistant/components/flume/const.py +++ b/homeassistant/components/flume/const.py @@ -29,7 +29,7 @@ FLUME_TYPE_SENSOR = 2 FLUME_AUTH = "flume_auth" FLUME_HTTP_SESSION = "http_session" FLUME_DEVICES = "devices" - +FLUME_NOTIFICATIONS_COORDINATOR = "notifications_coordinator" CONF_TOKEN_FILE = "token_filename" BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE" diff --git a/homeassistant/components/flume/services.yaml b/homeassistant/components/flume/services.yaml new file mode 100644 index 00000000000..e6f3d908a09 --- /dev/null +++ b/homeassistant/components/flume/services.yaml @@ -0,0 +1,7 @@ +list_notifications: + fields: + config_entry: + required: true + selector: + config_entry: + integration: flume diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json index 2c1a900c091..5f3021960b5 100644 --- a/homeassistant/components/flume/strings.json +++ b/homeassistant/components/flume/strings.json @@ -61,5 +61,17 @@ "name": "30 days" } } + }, + "services": { + "list_notifications": { + "name": "List notifications", + "description": "Return user notifications.", + "fields": { + "config_entry": { + "name": "Flume", + "description": "The flume config entry for which to return notifications." + } + } + } } } From 8d034a85fede5bd84914def3514db1e402b2c5fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 15:35:58 +0200 Subject: [PATCH 869/968] Small cleanup of nest tests (#102787) --- tests/components/nest/test_device_trigger.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index 181ad313c38..381cddb2817 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -304,13 +304,13 @@ async def test_fires_on_camera_sound( setup_platform: PlatformSetup, calls, ) -> None: - """Test camera_person triggers firing.""" + """Test camera_sound triggers firing.""" create_device.create( raw_data=make_camera( device_id=DEVICE_ID, traits={ "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.CameraSound": {}, }, ) ) @@ -344,7 +344,7 @@ async def test_fires_on_doorbell_chime( device_id=DEVICE_ID, traits={ "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, + "sdm.devices.traits.DoorbellChime": {}, }, ) ) @@ -372,7 +372,7 @@ async def test_trigger_for_wrong_device_id( setup_platform: PlatformSetup, calls, ) -> None: - """Test for turn_on and turn_off triggers firing.""" + """Test messages for the wrong device are ignored.""" create_device.create( raw_data=make_camera( device_id=DEVICE_ID, @@ -405,7 +405,7 @@ async def test_trigger_for_wrong_event_type( setup_platform: PlatformSetup, calls, ) -> None: - """Test for turn_on and turn_off triggers firing.""" + """Test that messages for the wrong event type are ignored.""" create_device.create( raw_data=make_camera( device_id=DEVICE_ID, From cd8e3a81dbf8827de675f4e24bc402dfef56f0d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 15:51:52 +0200 Subject: [PATCH 870/968] Add Update coordinator to QBittorrent (#98896) --- .coveragerc | 1 + .../components/qbittorrent/__init__.py | 7 +- .../components/qbittorrent/coordinator.py | 38 ++++++ .../components/qbittorrent/sensor.py | 113 +++++++++--------- 4 files changed, 103 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/qbittorrent/coordinator.py diff --git a/.coveragerc b/.coveragerc index 86b92c07a3d..5ef7ece3bd8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -989,6 +989,7 @@ omit = homeassistant/components/pushsafer/notify.py homeassistant/components/pyload/sensor.py homeassistant/components/qbittorrent/__init__.py + homeassistant/components/qbittorrent/coordinator.py homeassistant/components/qbittorrent/sensor.py homeassistant/components/qnap/__init__.py homeassistant/components/qnap/coordinator.py diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py index 53e8d4b9660..fd9577f5c73 100644 --- a/homeassistant/components/qbittorrent/__init__.py +++ b/homeassistant/components/qbittorrent/__init__.py @@ -16,6 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator from .helpers import setup_client PLATFORMS = [Platform.SENSOR] @@ -27,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up qBittorrent from a config entry.""" hass.data.setdefault(DOMAIN, {}) try: - hass.data[DOMAIN][entry.entry_id] = await hass.async_add_executor_job( + client = await hass.async_add_executor_job( setup_client, entry.data[CONF_URL], entry.data[CONF_USERNAME], @@ -38,7 +39,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady("Invalid credentials") from err except RequestException as err: raise ConfigEntryNotReady("Failed to connect") from err + coordinator = QBittorrentDataCoordinator(hass, client) + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN][entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/qbittorrent/coordinator.py b/homeassistant/components/qbittorrent/coordinator.py new file mode 100644 index 00000000000..8363a764d0a --- /dev/null +++ b/homeassistant/components/qbittorrent/coordinator.py @@ -0,0 +1,38 @@ +"""The QBittorrent coordinator.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from typing import Any + +from qbittorrent import Client +from qbittorrent.client import LoginRequired + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class QBittorrentDataCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """QBittorrent update coordinator.""" + + def __init__(self, hass: HomeAssistant, client: Client) -> None: + """Initialize coordinator.""" + self.client = client + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + + async def _async_update_data(self) -> dict[str, Any]: + try: + return await self.hass.async_add_executor_job(self.client.sync_main_data) + except LoginRequired as exc: + raise ConfigEntryError("Invalid authentication") from exc diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py index 5cca77ecc34..e2feee1e60c 100644 --- a/homeassistant/components/qbittorrent/sensor.py +++ b/homeassistant/components/qbittorrent/sensor.py @@ -1,10 +1,10 @@ """Support for monitoring the qBittorrent API.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass import logging - -from qbittorrent.client import Client, LoginRequired -from requests.exceptions import RequestException +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -16,8 +16,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, UnitOfDataRate from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import QBittorrentDataCoordinator _LOGGER = logging.getLogger(__name__) @@ -25,26 +28,61 @@ SENSOR_TYPE_CURRENT_STATUS = "current_status" SENSOR_TYPE_DOWNLOAD_SPEED = "download_speed" SENSOR_TYPE_UPLOAD_SPEED = "upload_speed" -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass +class QBittorrentMixin: + """Mixin for required keys.""" + + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass +class QBittorrentSensorEntityDescription(SensorEntityDescription, QBittorrentMixin): + """Describes QBittorrent sensor entity.""" + + +def _get_qbittorrent_state(data: dict[str, Any]) -> str: + download = data["server_state"]["dl_info_speed"] + upload = data["server_state"]["up_info_speed"] + + if upload > 0 and download > 0: + return "up_down" + if upload > 0 and download == 0: + return "seeding" + if upload == 0 and download > 0: + return "downloading" + return STATE_IDLE + + +def format_speed(speed): + """Return a bytes/s measurement as a human readable string.""" + kb_spd = float(speed) / 1024 + return round(kb_spd, 2 if kb_spd < 0.1 else 1) + + +SENSOR_TYPES: tuple[QBittorrentSensorEntityDescription, ...] = ( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_CURRENT_STATUS, name="Status", + value_fn=_get_qbittorrent_state, ), - SensorEntityDescription( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_DOWNLOAD_SPEED, name="Down Speed", icon="mdi:cloud-download", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["dl_info_speed"]), ), - SensorEntityDescription( + QBittorrentSensorEntityDescription( key=SENSOR_TYPE_UPLOAD_SPEED, name="Up Speed", icon="mdi:cloud-upload", device_class=SensorDeviceClass.DATA_RATE, native_unit_of_measurement=UnitOfDataRate.KIBIBYTES_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: format_speed(data["server_state"]["up_info_speed"]), ), ) @@ -55,68 +93,33 @@ async def async_setup_entry( async_add_entites: AddEntitiesCallback, ) -> None: """Set up qBittorrent sensor entries.""" - client: Client = hass.data[DOMAIN][config_entry.entry_id] + coordinator: QBittorrentDataCoordinator = hass.data[DOMAIN][config_entry.entry_id] entities = [ - QBittorrentSensor(description, client, config_entry) + QBittorrentSensor(description, coordinator, config_entry) for description in SENSOR_TYPES ] - async_add_entites(entities, True) + async_add_entites(entities) -def format_speed(speed): - """Return a bytes/s measurement as a human readable string.""" - kb_spd = float(speed) / 1024 - return round(kb_spd, 2 if kb_spd < 0.1 else 1) +class QBittorrentSensor(CoordinatorEntity[QBittorrentDataCoordinator], SensorEntity): + """Representation of a qBittorrent sensor.""" - -class QBittorrentSensor(SensorEntity): - """Representation of an qBittorrent sensor.""" + entity_description: QBittorrentSensorEntityDescription def __init__( self, - description: SensorEntityDescription, - qbittorrent_client: Client, + description: QBittorrentSensorEntityDescription, + coordinator: QBittorrentDataCoordinator, config_entry: ConfigEntry, ) -> None: """Initialize the qBittorrent sensor.""" + super().__init__(coordinator) self.entity_description = description - self.client = qbittorrent_client - self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_name = f"{config_entry.title} {description.name}" self._attr_available = False - def update(self) -> None: - """Get the latest data from qBittorrent and updates the state.""" - try: - data = self.client.sync_main_data() - self._attr_available = True - except RequestException: - _LOGGER.error("Connection lost") - self._attr_available = False - return - except LoginRequired: - _LOGGER.error("Invalid authentication") - return - - if data is None: - return - - download = data["server_state"]["dl_info_speed"] - upload = data["server_state"]["up_info_speed"] - - sensor_type = self.entity_description.key - if sensor_type == SENSOR_TYPE_CURRENT_STATUS: - if upload > 0 and download > 0: - self._attr_native_value = "up_down" - elif upload > 0 and download == 0: - self._attr_native_value = "seeding" - elif upload == 0 and download > 0: - self._attr_native_value = "downloading" - else: - self._attr_native_value = STATE_IDLE - - elif sensor_type == SENSOR_TYPE_DOWNLOAD_SPEED: - self._attr_native_value = format_speed(download) - elif sensor_type == SENSOR_TYPE_UPLOAD_SPEED: - self._attr_native_value = format_speed(upload) + @property + def native_value(self) -> StateType: + """Return value of sensor.""" + return self.entity_description.value_fn(self.coordinator.data) From e734a4bc533d56b6a9fd2fc6c95517b8555f48d1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 25 Oct 2023 16:09:09 +0200 Subject: [PATCH 871/968] Use sentence case in Random entities default name (#102788) --- homeassistant/components/random/binary_sensor.py | 2 +- homeassistant/components/random/sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py index 33d60d4bfd8..9ada2ecd621 100644 --- a/homeassistant/components/random/binary_sensor.py +++ b/homeassistant/components/random/binary_sensor.py @@ -19,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -DEFAULT_NAME = "Random Binary Sensor" +DEFAULT_NAME = "Random binary sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py index 18b383b401e..8e77f026253 100644 --- a/homeassistant/components/random/sensor.py +++ b/homeassistant/components/random/sensor.py @@ -26,7 +26,7 @@ from .const import DEFAULT_MAX, DEFAULT_MIN ATTR_MAXIMUM = "maximum" ATTR_MINIMUM = "minimum" -DEFAULT_NAME = "Random Sensor" +DEFAULT_NAME = "Random sensor" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( From b83ada8c19e816b6db30725b1ee432258b3e207f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 25 Oct 2023 16:09:39 +0200 Subject: [PATCH 872/968] Use real devices in automation and script tests (#102785) --- tests/components/automation/test_init.py | 50 +++++++++++++++++------ tests/components/script/test_blueprint.py | 22 +++++++--- tests/components/script/test_init.py | 39 ++++++++++++++---- tests/helpers/test_script.py | 19 +++++++-- 4 files changed, 101 insertions(+), 29 deletions(-) diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 0d983864e44..6d83b00517d 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest +from homeassistant import config_entries import homeassistant.components.automation as automation from homeassistant.components.automation import ( ATTR_SOURCE, @@ -36,6 +37,7 @@ from homeassistant.core import ( callback, ) from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, SCRIPT_MODE_PARALLEL, @@ -49,6 +51,7 @@ from homeassistant.util import yaml import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, MockUser, assert_setup_component, async_capture_events, @@ -1589,8 +1592,31 @@ async def test_extraction_functions_unavailable_automation(hass: HomeAssistant) assert automation.entities_in_automation(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + condition_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + trigger_device_2 = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:04")}, + ) + await async_setup_component(hass, "homeassistant", {}) await async_setup_component(hass, "calendar", {"calendar": {"platform": "demo"}}) assert await async_setup_component( @@ -1652,7 +1678,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: }, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1670,7 +1696,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "domain": "light", "type": "turned_on", "entity_id": "light.trigger_2", - "device_id": "trigger-device-2", + "device_id": trigger_device_2.id, }, { "platform": "tag", @@ -1702,7 +1728,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1720,13 +1746,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: {"scene": "scene.hello"}, { "domain": "light", - "device_id": "device-in-both", + "device_id": device_in_both.id, "entity_id": "light.bla", "type": "turn_on", }, { "domain": "light", - "device_id": "device-in-last", + "device_id": device_in_last.id, "entity_id": "light.bla", "type": "turn_on", }, @@ -1755,7 +1781,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: ], "condition": { "condition": "device", - "device_id": "condition-device", + "device_id": condition_device.id, "domain": "light", "type": "is_on", "entity_id": "light.bla", @@ -1799,15 +1825,15 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(automation.automations_with_device(hass, "device-in-both")) == { + assert set(automation.automations_with_device(hass, device_in_both.id)) == { "automation.test1", "automation.test2", } assert set(automation.devices_in_automation(hass, "automation.test2")) == { - "trigger-device-2", - "condition-device", - "device-in-both", - "device-in-last", + trigger_device_2.id, + condition_device.id, + device_in_both.id, + device_in_last.id, "device-trigger-event", "device-trigger-tag1", "device-trigger-tag2", diff --git a/tests/components/script/test_blueprint.py b/tests/components/script/test_blueprint.py index 8368eb06140..b248a3d7650 100644 --- a/tests/components/script/test_blueprint.py +++ b/tests/components/script/test_blueprint.py @@ -7,14 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.blueprint.models import Blueprint, DomainBlueprints from homeassistant.core import Context, HomeAssistant, callback -from homeassistant.helpers import template +from homeassistant.helpers import device_registry as dr, template from homeassistant.setup import async_setup_component from homeassistant.util import yaml -from tests.common import async_mock_service +from tests.common import MockConfigEntry, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(script.__file__).parent / "blueprints" @@ -41,8 +42,19 @@ def patch_blueprint(blueprint_path: str, data_path: str) -> Iterator[None]: yield -async def test_confirmable_notification(hass: HomeAssistant) -> None: +async def test_confirmable_notification( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test confirmable notification blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + frodo = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) + with patch_blueprint( "confirmable_notification.yaml", BUILTIN_BLUEPRINT_FOLDER / "confirmable_notification.yaml", @@ -56,7 +68,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "use_blueprint": { "path": "confirmable_notification.yaml", "input": { - "notify_device": "frodo", + "notify_device": frodo.id, "title": "Lord of the things", "message": "Throw ring in mountain?", "confirm_action": [ @@ -105,7 +117,7 @@ async def test_confirmable_notification(hass: HomeAssistant) -> None: "alias": "Send notification", "domain": "mobile_app", "type": "notify", - "device_id": "frodo", + "device_id": frodo.id, "data": { "actions": [ {"action": "CONFIRM_" + _context.id, "title": "Confirm"}, diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index cddefc8d3dc..83abd37137e 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch import pytest +from homeassistant import config_entries from homeassistant.components import script from homeassistant.components.script import DOMAIN, EVENT_SCRIPT_STARTED, ScriptEntity from homeassistant.const import ( @@ -27,7 +28,7 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.exceptions import ServiceNotFound -from homeassistant.helpers import entity_registry as er, template +from homeassistant.helpers import device_registry as dr, entity_registry as er, template from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, @@ -42,7 +43,12 @@ from homeassistant.setup import async_setup_component from homeassistant.util import yaml import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, async_mock_service, mock_restore_cache +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_mock_service, + mock_restore_cache, +) from tests.components.logbook.common import MockRow, mock_humanify from tests.typing import WebSocketGenerator @@ -707,8 +713,23 @@ async def test_extraction_functions_unavailable_script(hass: HomeAssistant) -> N assert script.entities_in_script(hass, entity_id) == [] -async def test_extraction_functions(hass: HomeAssistant) -> None: +async def test_extraction_functions( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test extraction functions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device_in_both = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + device_in_last = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:03")}, + ) + assert await async_setup_component( hass, DOMAIN, @@ -728,7 +749,7 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "service": "test.test", @@ -752,13 +773,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "entity_id": "light.device_in_both", "domain": "light", "type": "turn_on", - "device_id": "device-in-both", + "device_id": device_in_both.id, }, { "entity_id": "light.device_in_last", "domain": "light", "type": "turn_on", - "device_id": "device-in-last", + "device_id": device_in_last.id, }, ], }, @@ -797,13 +818,13 @@ async def test_extraction_functions(hass: HomeAssistant) -> None: "light.in_both", "light.in_first", } - assert set(script.scripts_with_device(hass, "device-in-both")) == { + assert set(script.scripts_with_device(hass, device_in_both.id)) == { "script.test1", "script.test2", } assert set(script.devices_in_script(hass, "script.test2")) == { - "device-in-both", - "device-in-last", + device_in_both.id, + device_in_last.id, } assert set(script.scripts_with_area(hass, "area-in-both")) == { "script.test1", diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 8e4409daa54..6c327345881 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -13,7 +13,7 @@ import pytest import voluptuous as vol # Otherwise can't test just this file (import order issue) -from homeassistant import exceptions +from homeassistant import config_entries, exceptions import homeassistant.components.scene as scene from homeassistant.const import ( ATTR_ENTITY_ID, @@ -33,6 +33,7 @@ from homeassistant.core import ( from homeassistant.exceptions import ConditionError, HomeAssistantError, ServiceNotFound from homeassistant.helpers import ( config_validation as cv, + device_registry as dr, entity_registry as er, script, template, @@ -43,6 +44,7 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import ( + MockConfigEntry, async_capture_events, async_fire_time_changed, async_mock_service, @@ -4532,12 +4534,23 @@ async def test_set_redefines_variable( assert_action_trace(expected_trace) -async def test_validate_action_config(hass: HomeAssistant) -> None: +async def test_validate_action_config( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Validate action config.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + mock_device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:02")}, + ) + def templated_device_action(message): return { - "device_id": "abcd", + "device_id": mock_device.id, "domain": "mobile_app", "message": f"{message} {{{{ 5 + 5}}}}", "type": "notify", From 4cac20f8351a599bbd15dba67d637a32f2ba9fc6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Oct 2023 16:22:19 +0200 Subject: [PATCH 873/968] Fix google_tasks generic typing (#102778) --- homeassistant/components/google_tasks/coordinator.py | 2 +- homeassistant/components/google_tasks/todo.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_tasks/coordinator.py b/homeassistant/components/google_tasks/coordinator.py index ab03cd52ec8..5377e2be567 100644 --- a/homeassistant/components/google_tasks/coordinator.py +++ b/homeassistant/components/google_tasks/coordinator.py @@ -16,7 +16,7 @@ UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 -class TaskUpdateCoordinator(DataUpdateCoordinator): +class TaskUpdateCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]): """Coordinator for fetching Google Tasks for a Task List form the API.""" def __init__( diff --git a/homeassistant/components/google_tasks/todo.py b/homeassistant/components/google_tasks/todo.py index 62220303932..5d2da33da71 100644 --- a/homeassistant/components/google_tasks/todo.py +++ b/homeassistant/components/google_tasks/todo.py @@ -58,7 +58,9 @@ async def async_setup_entry( ) -class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): +class GoogleTaskTodoListEntity( + CoordinatorEntity[TaskUpdateCoordinator], TodoListEntity +): """A To-do List representation of the Shopping List.""" _attr_has_entity_name = True @@ -89,7 +91,7 @@ class GoogleTaskTodoListEntity(CoordinatorEntity, TodoListEntity): summary=item["title"], uid=item["id"], status=TODO_STATUS_MAP.get( - item.get("status"), TodoItemStatus.NEEDS_ACTION + item.get("status"), TodoItemStatus.NEEDS_ACTION # type: ignore[arg-type] ), ) for item in self.coordinator.data From bcade5fe73eef173846ed6cefae4ebe09e883d1e Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 25 Oct 2023 16:51:42 +0200 Subject: [PATCH 874/968] Bump python-matter-server to version 4.0.0 (#102786) --- homeassistant/components/matter/climate.py | 4 ++-- homeassistant/components/matter/event.py | 2 +- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 44e5d30fec4..a22f9174d2a 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -44,7 +44,7 @@ HVAC_SYSTEM_MODE_MAP = { } SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence -ThermostatFeature = clusters.Thermostat.Bitmaps.ThermostatFeature +ThermostatFeature = clusters.Thermostat.Bitmaps.Feature class ThermostatRunningState(IntEnum): @@ -268,7 +268,7 @@ class MatterClimate(MatterEntity, ClimateEntity): @staticmethod def _create_optional_setpoint_command( - mode: clusters.Thermostat.Enums.SetpointAdjustMode, + mode: clusters.Thermostat.Enums.SetpointAdjustMode | int, target_temp: float, current_target_temp: float, ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 84049301296..3361c3fa146 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -21,7 +21,7 @@ from .entity import MatterEntity from .helpers import get_matter from .models import MatterDiscoverySchema -SwitchFeature = clusters.Switch.Bitmaps.SwitchFeature +SwitchFeature = clusters.Switch.Bitmaps.Feature EVENT_TYPES_MAP = { # mapping from raw event id's to translation keys diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 2237f0ade98..6f494153a97 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==3.7.0"] + "requirements": ["python-matter-server==4.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f65ef0f33a1..c46f8b9008e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2147,7 +2147,7 @@ python-kasa[speedups]==0.5.3 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51bcc8562e1..808d7151e1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.5.3 # homeassistant.components.matter -python-matter-server==3.7.0 +python-matter-server==4.0.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 8b1cfbc46cc79e676f75dfa4da097a2e47375b6f Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 25 Oct 2023 11:22:33 -0400 Subject: [PATCH 875/968] Bump zwave-js-server-python to 0.53.1 (#102790) --- homeassistant/components/zwave_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/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 505196c43eb..f0c1dcec6b5 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.52.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.53.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index c46f8b9008e..7d5f24f0b82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2821,7 +2821,7 @@ zigpy==0.59.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 808d7151e1e..e46c62e8976 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2106,7 +2106,7 @@ zigpy-znp==0.11.6 zigpy==0.59.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.52.1 +zwave-js-server-python==0.53.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 05fd64fe802b82a04888244e65e012611d8eb912 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 Oct 2023 17:41:53 +0200 Subject: [PATCH 876/968] Bumped version to 2023.11.0b0 --- 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 77c5582464e..5b8e9f43b21 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 82bb7d08e26..8068b7e55b7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0.dev0" +version = "2023.11.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 5b0e0b07b3362a3615cfc586084397f5cea696d4 Mon Sep 17 00:00:00 2001 From: Amit Finkelstein Date: Thu, 26 Oct 2023 11:46:20 +0300 Subject: [PATCH 877/968] Apple TV: Use replacement commands for deprecated ones (#102056) Co-authored-by: Robert Resch --- homeassistant/components/apple_tv/remote.py | 17 ++++++++++++- tests/components/apple_tv/test_remote.py | 28 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 tests/components/apple_tv/test_remote.py diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py index f3be6977891..bab3421c58d 100644 --- a/homeassistant/components/apple_tv/remote.py +++ b/homeassistant/components/apple_tv/remote.py @@ -21,6 +21,15 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 0 +COMMAND_TO_ATTRIBUTE = { + "wakeup": ("power", "turn_on"), + "suspend": ("power", "turn_off"), + "turn_on": ("power", "turn_on"), + "turn_off": ("power", "turn_off"), + "volume_up": ("audio", "volume_up"), + "volume_down": ("audio", "volume_down"), + "home_hold": ("remote_control", "home"), +} async def async_setup_entry( @@ -61,7 +70,13 @@ class AppleTVRemote(AppleTVEntity, RemoteEntity): for _ in range(num_repeats): for single_command in command: - attr_value = getattr(self.atv.remote_control, single_command, None) + attr_value = None + if attributes := COMMAND_TO_ATTRIBUTE.get(single_command): + attr_value = self.atv + for attr_name in attributes: + attr_value = getattr(attr_value, attr_name, None) + if not attr_value: + attr_value = getattr(self.atv.remote_control, single_command, None) if not attr_value: raise ValueError("Command not found. Exiting sequence") diff --git a/tests/components/apple_tv/test_remote.py b/tests/components/apple_tv/test_remote.py new file mode 100644 index 00000000000..db2a4964f6c --- /dev/null +++ b/tests/components/apple_tv/test_remote.py @@ -0,0 +1,28 @@ +"""Test apple_tv remote.""" +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.apple_tv.remote import AppleTVRemote +from homeassistant.components.remote import ATTR_DELAY_SECS, ATTR_NUM_REPEATS + + +@pytest.mark.parametrize( + ("command", "method"), + [ + ("up", "remote_control.up"), + ("wakeup", "power.turn_on"), + ("volume_up", "audio.volume_up"), + ("home_hold", "remote_control.home"), + ], + ids=["up", "wakeup", "volume_up", "home_hold"], +) +async def test_send_command(command: str, method: str) -> None: + """Test "send_command" method.""" + remote = AppleTVRemote("test", "test", None) + remote.atv = AsyncMock() + await remote.async_send_command( + [command], **{ATTR_NUM_REPEATS: 1, ATTR_DELAY_SECS: 0} + ) + assert len(remote.atv.method_calls) == 1 + assert str(remote.atv.method_calls[0]) == f"call.{method}()" From bbcfb5f30e46e27094ba207aeb888452aeebcf15 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Thu, 26 Oct 2023 21:34:59 +0200 Subject: [PATCH 878/968] Improve exception handling for Vodafone Station (#102761) * improve exception handling for Vodafone Station * address review comment * apply review comment * better except handling (bump library) * cleanup --- .../vodafone_station/coordinator.py | 22 +++++++++++-------- .../components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 38fc80ac3af..a2cddcf9a65 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -95,15 +95,19 @@ class VodafoneStationRouter(DataUpdateCoordinator[UpdateCoordinatorDataType]): """Update router data.""" _LOGGER.debug("Polling Vodafone Station host: %s", self._host) try: - logged = await self.api.login() - except exceptions.CannotConnect as err: - _LOGGER.warning("Connection error for %s", self._host) - raise UpdateFailed(f"Error fetching data: {repr(err)}") from err - except exceptions.CannotAuthenticate as err: - raise ConfigEntryAuthFailed from err - - if not logged: - raise ConfigEntryAuthFailed + try: + await self.api.login() + except exceptions.CannotAuthenticate as err: + raise ConfigEntryAuthFailed from err + except ( + exceptions.CannotConnect, + exceptions.AlreadyLogged, + exceptions.GenericLoginError, + ) as err: + raise UpdateFailed(f"Error fetching data: {repr(err)}") from err + except (ConfigEntryAuthFailed, UpdateFailed): + await self.api.close() + raise utc_point_in_time = dt_util.utcnow() data_devices = { diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 628c25b987e..2a1814c83d0 100644 --- a/homeassistant/components/vodafone_station/manifest.json +++ b/homeassistant/components/vodafone_station/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vodafone_station", "iot_class": "local_polling", "loggers": ["aiovodafone"], - "requirements": ["aiovodafone==0.4.1"] + "requirements": ["aiovodafone==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7d5f24f0b82..aa93dd46f26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -375,7 +375,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e46c62e8976..dc8a40bc325 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -350,7 +350,7 @@ aiounifi==64 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.4.1 +aiovodafone==0.4.2 # homeassistant.components.waqi aiowaqi==2.1.0 From 62733e830f2fd55a1d74d1534901ddebcde2e8d1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 09:46:16 +0200 Subject: [PATCH 879/968] Improve validation of device automations (#102766) * Improve validation of device automations * Improve comments * Address review comment --- .../components/device_automation/helpers.py | 37 +++-- .../components/device_automation/test_init.py | 148 +++++++++++++----- 2 files changed, 133 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/device_automation/helpers.py b/homeassistant/components/device_automation/helpers.py index 83c599bc65d..a00455293f6 100644 --- a/homeassistant/components/device_automation/helpers.py +++ b/homeassistant/components/device_automation/helpers.py @@ -5,9 +5,9 @@ from typing import cast import voluptuous as vol -from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, Platform +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType from . import DeviceAutomationType, async_get_device_automation_platform @@ -55,31 +55,42 @@ async def async_validate_device_automation_config( platform = await async_get_device_automation_platform( hass, validated_config[CONF_DOMAIN], automation_type ) + + # Make sure the referenced device and optional entity exist + device_registry = dr.async_get(hass) + if not (device := device_registry.async_get(validated_config[CONF_DEVICE_ID])): + # The device referenced by the device automation does not exist + raise InvalidDeviceAutomationConfig( + f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" + ) + if entity_id := validated_config.get(CONF_ENTITY_ID): + try: + er.async_validate_entity_id(er.async_get(hass), entity_id) + except vol.Invalid as err: + raise InvalidDeviceAutomationConfig( + f"Unknown entity '{entity_id}'" + ) from err + if not hasattr(platform, DYNAMIC_VALIDATOR[automation_type]): # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, getattr(platform, STATIC_VALIDATOR[automation_type])(config) ) - # Bypass checks for entity platforms + # Devices are not linked to config entries from entity platform domains, skip + # the checks below which look for a config entry matching the device automation + # domain if ( automation_type == DeviceAutomationType.ACTION and validated_config[CONF_DOMAIN] in ENTITY_PLATFORMS ): + # Pass the unvalidated config to avoid mutating the raw config twice return cast( ConfigType, await getattr(platform, DYNAMIC_VALIDATOR[automation_type])(hass, config), ) - # Only call the dynamic validator if the referenced device exists and the relevant - # config entry is loaded - registry = dr.async_get(hass) - if not (device := registry.async_get(validated_config[CONF_DEVICE_ID])): - # The device referenced by the device automation does not exist - raise InvalidDeviceAutomationConfig( - f"Unknown device '{validated_config[CONF_DEVICE_ID]}'" - ) - + # Find a config entry with the same domain as the device automation device_config_entry = None for entry_id in device.config_entries: if ( @@ -91,7 +102,7 @@ async def async_validate_device_automation_config( break if not device_config_entry: - # The config entry referenced by the device automation does not exist + # There's no config entry with the same domain as the device automation raise InvalidDeviceAutomationConfig( f"Device '{validated_config[CONF_DEVICE_ID]}' has no config entry from " f"domain '{validated_config[CONF_DOMAIN]}'" diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 3a7105684f4..457b7ccbf9b 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -1,6 +1,7 @@ """The test for light device automation.""" from unittest.mock import AsyncMock, Mock, patch +import attr import pytest from pytest_unordered import unordered import voluptuous as vol @@ -31,6 +32,13 @@ from tests.common import ( from tests.typing import WebSocketGenerator +@attr.s(frozen=True) +class MockDeviceEntry(dr.DeviceEntry): + """Device Registry Entry with fixed UUID.""" + + id: str = attr.ib(default="very_unique") + + @pytest.fixture(autouse=True, name="stub_blueprint_populate") def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" @@ -1240,17 +1248,56 @@ async def test_automation_with_integration_without_device_trigger( ) +BAD_AUTOMATIONS = [ + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "wrong"}, + "required key not provided @ data{path}['domain']", + ), + ( + {"device_id": "wrong", "domain": "light"}, + "Unknown device 'wrong'", + ), + ( + {"device_id": "very_unique", "domain": "light"}, + "required key not provided @ data['entity_id']", + ), + ( + {"device_id": "very_unique", "domain": "light", "entity_id": "wrong"}, + "Unknown entity 'wrong'", + ), +] + +BAD_TRIGGERS = BAD_CONDITIONS = BAD_AUTOMATIONS + [ + ( + {"domain": "light"}, + "required key not provided @ data{path}['device_id']", + ) +] + + +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("action", "expected_error"), BAD_AUTOMATIONS) async def test_automation_with_bad_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + action: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1262,25 +1309,29 @@ async def test_automation_with_bad_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": {"device_id": device_entry.id, "domain": "light"}, + "action": action, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition_action( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device action.""" config_entry = MockConfigEntry(domain="fake_integration", data={}) config_entry.state = config_entries.ConfigEntryState.LOADED config_entry.add_to_hass(hass) - device_entry = device_registry.async_get_or_create( + device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) @@ -1292,42 +1343,32 @@ async def test_automation_with_bad_condition_action( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "action": { - "condition": "device", - "device_id": device_entry.id, - "domain": "light", - }, + "action": {"condition": "device"} | condition, } }, ) - assert "required key not provided" in caplog.text - - -async def test_automation_with_bad_condition_missing_domain( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test automation with bad device condition.""" - assert await async_setup_component( - hass, - automation.DOMAIN, - { - automation.DOMAIN: { - "alias": "hello", - "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "device_id": "hello.device"}, - "action": {"service": "test.automation", "entity_id": "hello.world"}, - } - }, - ) - - assert "required key not provided @ data['condition'][0]['domain']" in caplog.text + assert expected_error.format(path="['action'][0]") in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1335,13 +1376,13 @@ async def test_automation_with_bad_condition( automation.DOMAIN: { "alias": "hello", "trigger": {"platform": "event", "event_type": "test_event1"}, - "condition": {"condition": "device", "domain": "light"}, + "condition": {"condition": "device"} | condition, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="['condition'][0]") in caplog.text @pytest.fixture @@ -1475,10 +1516,24 @@ async def test_automation_with_sub_condition( ) +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("condition", "expected_error"), BAD_CONDITIONS) async def test_automation_with_bad_sub_condition( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + condition: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device condition under and/or conditions.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, @@ -1488,33 +1543,48 @@ async def test_automation_with_bad_sub_condition( "trigger": {"platform": "event", "event_type": "test_event1"}, "condition": { "condition": "and", - "conditions": [{"condition": "device", "domain": "light"}], + "conditions": [{"condition": "device"} | condition], }, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + path = "['condition'][0]['conditions'][0]" + assert expected_error.format(path=path) in caplog.text +@patch("homeassistant.helpers.device_registry.DeviceEntry", MockDeviceEntry) +@pytest.mark.parametrize(("trigger", "expected_error"), BAD_TRIGGERS) async def test_automation_with_bad_trigger( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + device_registry: dr.DeviceRegistry, + trigger: dict[str, str], + expected_error: str, ) -> None: """Test automation with bad device trigger.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + assert await async_setup_component( hass, automation.DOMAIN, { automation.DOMAIN: { "alias": "hello", - "trigger": {"platform": "device", "domain": "light"}, + "trigger": {"platform": "device"} | trigger, "action": {"service": "test.automation", "entity_id": "hello.world"}, } }, ) - assert "required key not provided" in caplog.text + assert expected_error.format(path="") in caplog.text async def test_websocket_device_not_found( From 0a0584b0533625be58ac73e732474a11d2717199 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:09:36 +0200 Subject: [PATCH 880/968] Fix velbus import (#102780) --- homeassistant/components/velbus/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py index 5c35303f859..1888a177895 100644 --- a/homeassistant/components/velbus/config_flow.py +++ b/homeassistant/components/velbus/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -import velbusaio +import velbusaio.controller from velbusaio.exceptions import VelbusConnectionFailed import voluptuous as vol From 5fe5013198965a86b9f99228bb2774770479b1c6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 26 Oct 2023 09:43:10 -0700 Subject: [PATCH 881/968] Change todo move API to reference previous uid (#102795) --- homeassistant/components/local_todo/todo.py | 28 +++++--- .../components/shopping_list/__init__.py | 24 ++++--- .../components/shopping_list/todo.py | 6 +- homeassistant/components/todo/__init__.py | 18 +++-- pylint/plugins/hass_enforce_type_hints.py | 2 +- tests/components/local_todo/test_todo.py | 72 ++++++++++++++----- tests/components/shopping_list/test_todo.py | 35 +++++---- tests/components/todo/test_init.py | 15 ++-- 8 files changed, 131 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 14d14316faf..7e23d01ee46 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -139,20 +139,28 @@ class LocalTodoListEntity(TodoListEntity): await self._async_save() await self.async_update_ha_state(force_refresh=True) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" + if uid == previous_uid: + return todos = self._calendar.todos - found_item: Todo | None = None - for idx, itm in enumerate(todos): - if itm.uid == uid: - found_item = itm - todos.pop(idx) - break - if found_item is None: + item_idx: dict[str, int] = {itm.uid: idx for idx, itm in enumerate(todos)} + if uid not in item_idx: raise HomeAssistantError( - f"Item '{uid}' not found in todo list {self.entity_id}" + "Item '{uid}' not found in todo list {self.entity_id}" ) - todos.insert(pos, found_item) + if previous_uid and previous_uid not in item_idx: + raise HomeAssistantError( + "Item '{previous_uid}' not found in todo list {self.entity_id}" + ) + dst_idx = item_idx[previous_uid] + 1 if previous_uid else 0 + src_idx = item_idx[uid] + src_item = todos.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + todos.insert(dst_idx, src_item) await self._async_save() await self.async_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index f2de59b10af..e2f04b5d880 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -322,17 +322,23 @@ class ShoppingData: context=context, ) - async def async_move_item(self, uid: str, pos: int) -> None: + async def async_move_item(self, uid: str, previous: str | None = None) -> None: """Re-order a shopping list item.""" - found_item: dict[str, Any] | None = None - for idx, itm in enumerate(self.items): - if cast(str, itm["id"]) == uid: - found_item = itm - self.items.pop(idx) - break - if not found_item: + if uid == previous: + return + item_idx = {cast(str, itm["id"]): idx for idx, itm in enumerate(self.items)} + if uid not in item_idx: raise NoMatchingShoppingListItem(f"Item '{uid}' not found in shopping list") - self.items.insert(pos, found_item) + if previous and previous not in item_idx: + raise NoMatchingShoppingListItem( + f"Item '{previous}' not found in shopping list" + ) + dst_idx = item_idx[previous] + 1 if previous else 0 + src_idx = item_idx[uid] + src_item = self.items.pop(src_idx) + if dst_idx > src_idx: + dst_idx -= 1 + self.items.insert(dst_idx, src_item) await self.hass.async_add_executor_job(self.save) self._async_notify() self.hass.bus.async_fire( diff --git a/homeassistant/components/shopping_list/todo.py b/homeassistant/components/shopping_list/todo.py index 53c9e6b6d74..d89f376d662 100644 --- a/homeassistant/components/shopping_list/todo.py +++ b/homeassistant/components/shopping_list/todo.py @@ -71,11 +71,13 @@ class ShoppingTodoListEntity(TodoListEntity): """Add an item to the To-do list.""" await self._data.async_remove_items(set(uids)) - async def async_move_todo_item(self, uid: str, pos: int) -> None: + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: """Re-order an item to the To-do list.""" try: - await self._data.async_move_item(uid, pos) + await self._data.async_move_item(uid, previous_uid) except NoMatchingShoppingListItem as err: raise HomeAssistantError( f"Shopping list item '{uid}' could not be re-ordered" diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index a6660b0231a..12eac858f75 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -152,8 +152,15 @@ class TodoListEntity(Entity): """Delete an item in the To-do list.""" raise NotImplementedError() - async def async_move_todo_item(self, uid: str, pos: int) -> None: - """Move an item in the To-do list.""" + async def async_move_todo_item( + self, uid: str, previous_uid: str | None = None + ) -> None: + """Move an item in the To-do list. + + The To-do item with the specified `uid` should be moved to the position + in the list after the specified by `previous_uid` or `None` for the first + position in the To-do list. + """ raise NotImplementedError() @@ -190,7 +197,7 @@ async def websocket_handle_todo_item_list( vol.Required("type"): "todo/item/move", vol.Required("entity_id"): cv.entity_id, vol.Required("uid"): cv.string, - vol.Optional("pos", default=0): cv.positive_int, + vol.Optional("previous_uid"): cv.string, } ) @websocket_api.async_response @@ -215,9 +222,10 @@ async def websocket_handle_todo_item_move( ) ) return - try: - await entity.async_move_todo_item(uid=msg["uid"], pos=msg["pos"]) + await entity.async_move_todo_item( + uid=msg["uid"], previous_uid=msg.get("previous_uid") + ) except HomeAssistantError as ex: connection.send_error(msg["id"], "failed", str(ex)) else: diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 845b70b72ba..f43dd9b6672 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -2469,7 +2469,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="async_move_todo_item", arg_types={ 1: "str", - 2: "int", + 2: "str | None", }, return_type="None", ), diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 6d06649a6ba..8a7e38c9773 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -59,7 +59,7 @@ async def ws_move_item( ) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int) -> None: + async def move(uid: str, previous_uid: str | None) -> None: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -68,8 +68,9 @@ async def ws_move_item( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": uid, - "pos": pos, } + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -237,30 +238,29 @@ async def test_update_item( @pytest.mark.parametrize( - ("src_idx", "pos", "expected_items"), + ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), (0, 3, ["item 2", "item 3", "item 4", "item 1"]), (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), - (1, 4, ["item 1", "item 3", "item 4", "item 2"]), - (1, 5, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (0, 0, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -269,7 +269,7 @@ async def test_move_item( ws_get_items: Callable[[], Awaitable[dict[str, str]]], ws_move_item: Callable[[str, str | None], Awaitable[None]], src_idx: int, - pos: int, + dst_idx: int | None, expected_items: list[str], ) -> None: """Test moving a todo item within the list.""" @@ -289,7 +289,10 @@ async def test_move_item( assert summaries == ["item 1", "item 2", "item 3", "item 4"] # Prepare items for moving - await ws_move_item(uids[src_idx], pos) + previous_uid = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + await ws_move_item(uids[src_idx], previous_uid) items = await ws_get_items() assert len(items) == 4 @@ -311,7 +314,42 @@ async def test_move_item_unknown( "type": "todo/item/move", "entity_id": TEST_ENTITY, "uid": "unknown", - "pos": 0, + "previous_uid": "item-2", + } + await client.send_json(data) + resp = await client.receive_json() + assert resp.get("id") == 1 + assert not resp.get("success") + assert resp.get("error", {}).get("code") == "failed" + assert "not found in todo list" in resp["error"]["message"] + + +async def test_move_item_previous_unknown( + hass: HomeAssistant, + setup_integration: None, + hass_ws_client: WebSocketGenerator, + ws_get_items: Callable[[], Awaitable[dict[str, str]]], +) -> None: + """Test moving a todo item that does not exist.""" + + await hass.services.async_call( + TODO_DOMAIN, + "create_item", + {"summary": "item 1"}, + target={"entity_id": TEST_ENTITY}, + blocking=True, + ) + items = await ws_get_items() + assert len(items) == 1 + + # Prepare items for moving + client = await hass_ws_client() + data = { + "id": 1, + "type": "todo/item/move", + "entity_id": TEST_ENTITY, + "uid": items[0]["uid"], + "previous_uid": "unknown", } await client.send_json(data) resp = await client.receive_json() diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index 15f1e50bdb9..ab28c6cbe6d 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -57,10 +57,10 @@ async def ws_get_items( async def ws_move_item( hass_ws_client: WebSocketGenerator, ws_req_id: Callable[[], int], -) -> Callable[[str, int | None], Awaitable[None]]: +) -> Callable[[str, str | None], Awaitable[None]]: """Fixture to move an item in the todo list.""" - async def move(uid: str, pos: int | None) -> dict[str, Any]: + async def move(uid: str, previous_uid: str | None) -> dict[str, Any]: # Fetch items using To-do platform client = await hass_ws_client() id = ws_req_id() @@ -70,8 +70,8 @@ async def ws_move_item( "entity_id": TEST_ENTITY, "uid": uid, } - if pos is not None: - data["pos"] = pos + if previous_uid is not None: + data["previous_uid"] = previous_uid await client.send_json(data) resp = await client.receive_json() assert resp.get("id") == id @@ -406,10 +406,10 @@ async def test_update_invalid_item( ("src_idx", "dst_idx", "expected_items"), [ # Move any item to the front of the list - (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 0, ["item 2", "item 1", "item 3", "item 4"]), - (2, 0, ["item 3", "item 1", "item 2", "item 4"]), - (3, 0, ["item 4", "item 1", "item 2", "item 3"]), + (0, None, ["item 1", "item 2", "item 3", "item 4"]), + (1, None, ["item 2", "item 1", "item 3", "item 4"]), + (2, None, ["item 3", "item 1", "item 2", "item 4"]), + (3, None, ["item 4", "item 1", "item 2", "item 3"]), # Move items right (0, 1, ["item 2", "item 1", "item 3", "item 4"]), (0, 2, ["item 2", "item 3", "item 1", "item 4"]), @@ -417,15 +417,15 @@ async def test_update_invalid_item( (1, 2, ["item 1", "item 3", "item 2", "item 4"]), (1, 3, ["item 1", "item 3", "item 4", "item 2"]), # Move items left - (2, 1, ["item 1", "item 3", "item 2", "item 4"]), - (3, 1, ["item 1", "item 4", "item 2", "item 3"]), - (3, 2, ["item 1", "item 2", "item 4", "item 3"]), + (2, 0, ["item 1", "item 3", "item 2", "item 4"]), + (3, 0, ["item 1", "item 4", "item 2", "item 3"]), + (3, 1, ["item 1", "item 2", "item 4", "item 3"]), # No-ops (0, 0, ["item 1", "item 2", "item 3", "item 4"]), - (1, 1, ["item 1", "item 2", "item 3", "item 4"]), + (2, 1, ["item 1", "item 2", "item 3", "item 4"]), (2, 2, ["item 1", "item 2", "item 3", "item 4"]), + (3, 2, ["item 1", "item 2", "item 3", "item 4"]), (3, 3, ["item 1", "item 2", "item 3", "item 4"]), - (3, 4, ["item 1", "item 2", "item 3", "item 4"]), ], ) async def test_move_item( @@ -433,7 +433,7 @@ async def test_move_item( sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], - ws_move_item: Callable[[str, int | None], Awaitable[dict[str, Any]]], + ws_move_item: Callable[[str, str | None], Awaitable[dict[str, Any]]], src_idx: int, dst_idx: int | None, expected_items: list[str], @@ -457,7 +457,12 @@ async def test_move_item( summaries = [item["summary"] for item in items] assert summaries == ["item 1", "item 2", "item 3", "item 4"] - resp = await ws_move_item(uids[src_idx], dst_idx) + # Prepare items for moving + previous_uid: str | None = None + if dst_idx is not None: + previous_uid = uids[dst_idx] + + resp = await ws_move_item(uids[src_idx], previous_uid) assert resp.get("success") items = await ws_get_items() diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index 833a4ea266b..f4d671ad352 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -571,7 +571,7 @@ async def test_move_todo_item_service_by_id( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -581,7 +581,7 @@ async def test_move_todo_item_service_by_id( args = test_entity.async_move_todo_item.call_args assert args assert args.kwargs.get("uid") == "item-1" - assert args.kwargs.get("pos") == 1 + assert args.kwargs.get("previous_uid") == "item-2" async def test_move_todo_item_service_raises( @@ -601,7 +601,7 @@ async def test_move_todo_item_service_raises( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() @@ -620,15 +620,10 @@ async def test_move_todo_item_service_raises( ), ({"entity_id": "todo.entity1"}, "invalid_format", "required key not provided"), ( - {"entity_id": "todo.entity1", "pos": "2"}, + {"entity_id": "todo.entity1", "previous_uid": "item-2"}, "invalid_format", "required key not provided", ), - ( - {"entity_id": "todo.entity1", "uid": "item-1", "pos": "-2"}, - "invalid_format", - "value must be at least 0", - ), ], ) async def test_move_todo_item_service_invalid_input( @@ -722,7 +717,7 @@ async def test_move_item_unsupported( "type": "todo/item/move", "entity_id": "todo.entity1", "uid": "item-1", - "pos": "1", + "previous_uid": "item-2", } ) resp = await client.receive_json() From 10e6a26717e5668bfa6c5ae6bcd7cf8da656db9f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:22:38 +0200 Subject: [PATCH 882/968] Fix fan device actions (#102797) --- homeassistant/components/fan/device_action.py | 14 ++++++++++++-- tests/components/fan/test_device_action.py | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fan/device_action.py b/homeassistant/components/fan/device_action.py index 55bd862349b..fc7f1ddce1f 100644 --- a/homeassistant/components/fan/device_action.py +++ b/homeassistant/components/fan/device_action.py @@ -3,14 +3,24 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.components.device_automation import toggle_entity +from homeassistant.components.device_automation import ( + async_validate_entity_schema, + toggle_entity, +) from homeassistant.const import CONF_DOMAIN from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.typing import ConfigType, TemplateVarsType from . import DOMAIN -ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) +_ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + + +async def async_validate_action_config( + hass: HomeAssistant, config: ConfigType +) -> ConfigType: + """Validate config.""" + return async_validate_entity_schema(hass, config, _ACTION_SCHEMA) async def async_get_actions( diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index 3b179bc158c..b8756d9ace5 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -171,6 +171,7 @@ async def test_action( hass.bus.async_fire("test_event_turn_off") await hass.async_block_till_done() assert len(turn_off_calls) == 1 + assert turn_off_calls[0].data["entity_id"] == entry.entity_id assert len(turn_on_calls) == 0 assert len(toggle_calls) == 0 @@ -178,6 +179,7 @@ async def test_action( await hass.async_block_till_done() assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 + assert turn_on_calls[0].data["entity_id"] == entry.entity_id assert len(toggle_calls) == 0 hass.bus.async_fire("test_event_toggle") @@ -185,6 +187,7 @@ async def test_action( assert len(turn_off_calls) == 1 assert len(turn_on_calls) == 1 assert len(toggle_calls) == 1 + assert toggle_calls[0].data["entity_id"] == entry.entity_id async def test_action_legacy( From 244fccdae621bf3c12cd48542ace9474a03b02ff Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 25 Oct 2023 17:57:47 -0400 Subject: [PATCH 883/968] Move coordinator first refresh in Blink (#102805) Move coordinator first refresh --- homeassistant/components/blink/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 89438c9c7c1..c6413dd4372 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -86,8 +86,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: blink.auth = Auth(auth_data, no_prompt=True, session=session) blink.refresh_rate = entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) coordinator = BlinkUpdateCoordinator(hass, blink) - await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator try: await blink.start() @@ -101,6 +99,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not blink.available: raise ConfigEntryNotReady + await coordinator.async_config_entry_first_refresh() + hass.data[DOMAIN][entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) From 701a5d775826a8683d05e57b7ace71f0c0a724a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 15:55:28 -0500 Subject: [PATCH 884/968] Bump HAP-python 4.9.1 (#102811) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index 4f6cc24edc8..17d1237e579 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["pyhap"], "requirements": [ - "HAP-python==4.9.0", + "HAP-python==4.9.1", "fnv-hash-fast==0.5.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index aa93dd46f26..ab9941778cc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -26,7 +26,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc8a40bc325..f40240690eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -25,7 +25,7 @@ CO2Signal==0.4.2 DoorBirdPy==2.1.0 # homeassistant.components.homekit -HAP-python==4.9.0 +HAP-python==4.9.1 # homeassistant.components.tasmota HATasmota==0.7.3 From f2cef7245ac36fdd257f73a6f8268bfd058288d6 Mon Sep 17 00:00:00 2001 From: William Scanlon <6432770+w1ll1am23@users.noreply.github.com> Date: Wed, 25 Oct 2023 23:25:44 -0400 Subject: [PATCH 885/968] Bump pyeconet to 0.1.22 to handle breaking API change (#102820) --- homeassistant/components/econet/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 3472ca231e9..26b04929a45 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/econet", "iot_class": "cloud_push", "loggers": ["paho_mqtt", "pyeconet"], - "requirements": ["pyeconet==0.1.20"] + "requirements": ["pyeconet==0.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index ab9941778cc..5986c17e07b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1684,7 +1684,7 @@ pyebox==1.1.4 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f40240690eb..bf76fe945ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1269,7 +1269,7 @@ pydroid-ipcam==2.0.0 pyecoforest==0.3.0 # homeassistant.components.econet -pyeconet==0.1.20 +pyeconet==0.1.22 # homeassistant.components.efergy pyefergy==22.1.1 From 767b7ba4d6e494fd3cef8ea21d9b5900873e70fa Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 01:08:31 +0200 Subject: [PATCH 886/968] Correct logic for picking bluetooth local name (#102823) * Correct logic for picking bluetooth local name * make test more robust --------- Co-authored-by: J. Nick Koston --- .../components/bluetooth/base_scanner.py | 2 +- .../components/bluetooth/test_base_scanner.py | 31 +++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/bluetooth/base_scanner.py b/homeassistant/components/bluetooth/base_scanner.py index 240610e4868..8eacd3e291a 100644 --- a/homeassistant/components/bluetooth/base_scanner.py +++ b/homeassistant/components/bluetooth/base_scanner.py @@ -330,7 +330,7 @@ class BaseHaRemoteScanner(BaseHaScanner): prev_manufacturer_data = prev_advertisement.manufacturer_data prev_name = prev_device.name - if local_name and prev_name and len(prev_name) > len(local_name): + if prev_name and (not local_name or len(prev_name) > len(local_name)): local_name = prev_name if service_uuids and service_uuids != prev_service_uuids: diff --git a/tests/components/bluetooth/test_base_scanner.py b/tests/components/bluetooth/test_base_scanner.py index fc870f2bfe3..31d90a6e93d 100644 --- a/tests/components/bluetooth/test_base_scanner.py +++ b/tests/components/bluetooth/test_base_scanner.py @@ -42,7 +42,10 @@ from . import ( from tests.common import async_fire_time_changed, load_fixture -async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> None: +@pytest.mark.parametrize("name_2", [None, "w"]) +async def test_remote_scanner( + hass: HomeAssistant, enable_bluetooth: None, name_2: str | None +) -> None: """Test the remote scanner base class merges advertisement_data.""" manager = _get_manager() @@ -61,12 +64,25 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No ) switchbot_device_2 = generate_ble_device( "44:44:33:11:23:45", - "w", + name_2, {}, rssi=-100, ) switchbot_device_adv_2 = generate_advertisement_data( - local_name="wohand", + local_name=name_2, + service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], + service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, + manufacturer_data={1: b"\x01", 2: b"\x02"}, + rssi=-100, + ) + switchbot_device_3 = generate_ble_device( + "44:44:33:11:23:45", + "wohandlonger", + {}, + rssi=-100, + ) + switchbot_device_adv_3 = generate_advertisement_data( + local_name="wohandlonger", service_uuids=["00000001-0000-1000-8000-00805f9b34fb"], service_data={"00000001-0000-1000-8000-00805f9b34fb": b"\n\xff"}, manufacturer_data={1: b"\x01", 2: b"\x02"}, @@ -125,6 +141,15 @@ async def test_remote_scanner(hass: HomeAssistant, enable_bluetooth: None) -> No "00000001-0000-1000-8000-00805f9b34fb", } + # The longer name should be used + scanner.inject_advertisement(switchbot_device_3, switchbot_device_adv_3) + assert discovered_device.name == switchbot_device_3.name + + # Inject the shorter name / None again to make + # sure we always keep the longer name + scanner.inject_advertisement(switchbot_device_2, switchbot_device_adv_2) + assert discovered_device.name == switchbot_device_3.name + cancel() unsetup() From 0d7fb5b0268827107c5674d4718a3b55b26128a2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 Oct 2023 05:20:50 +0200 Subject: [PATCH 887/968] Use real devices in automation blueprint tests (#102824) --- tests/components/automation/test_blueprint.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/tests/components/automation/test_blueprint.py b/tests/components/automation/test_blueprint.py index ad35a2cfbdd..2976886881d 100644 --- a/tests/components/automation/test_blueprint.py +++ b/tests/components/automation/test_blueprint.py @@ -7,13 +7,15 @@ from unittest.mock import patch import pytest +from homeassistant import config_entries from homeassistant.components import automation from homeassistant.components.blueprint import models from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util, yaml -from tests.common import async_fire_time_changed, async_mock_service +from tests.common import MockConfigEntry, async_fire_time_changed, async_mock_service BUILTIN_BLUEPRINT_FOLDER = pathlib.Path(automation.__file__).parent / "blueprints" @@ -40,8 +42,18 @@ def patch_blueprint(blueprint_path: str, data_path): yield -async def test_notify_leaving_zone(hass: HomeAssistant) -> None: +async def test_notify_leaving_zone( + hass: HomeAssistant, device_registry: dr.DeviceRegistry +) -> None: """Test notifying leaving a zone blueprint.""" + config_entry = MockConfigEntry(domain="fake_integration", data={}) + config_entry.state = config_entries.ConfigEntryState.LOADED + config_entry.add_to_hass(hass) + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "00:00:00:00:00:01")}, + ) def set_person_state(state, extra={}): hass.states.async_set( @@ -68,7 +80,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "input": { "person_entity": "person.test_person", "zone_entity": "zone.school", - "notify_device": "abcdefgh", + "notify_device": device.id, }, } } @@ -89,7 +101,7 @@ async def test_notify_leaving_zone(hass: HomeAssistant) -> None: "alias": "Notify that a person has left the zone", "domain": "mobile_app", "type": "notify", - "device_id": "abcdefgh", + "device_id": device.id, } message_tpl.hass = hass assert message_tpl.async_render(variables) == "Paulus has left School" From 386c5ecc3e1e1f8c685b1c58eacb29c7982f2d91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 Oct 2023 22:23:06 -0500 Subject: [PATCH 888/968] Bump bleak-retry-connector to 3.3.0 (#102825) changelog: https://github.com/Bluetooth-Devices/bleak-retry-connector/compare/v3.2.1...v3.3.0 --- 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 960a86637ae..06e7d34e68d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.1", - "bleak-retry-connector==3.2.1", + "bleak-retry-connector==3.3.0", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.13.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index def5f0c9afa..3aff4601d45 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==23.8.0 bcrypt==4.0.1 -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.3 diff --git a/requirements_all.txt b/requirements_all.txt index 5986c17e07b..befc5e905cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -530,7 +530,7 @@ bimmer-connected==0.14.2 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf76fe945ef..34d7a6a4207 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -451,7 +451,7 @@ bellows==0.36.8 bimmer-connected==0.14.2 # homeassistant.components.bluetooth -bleak-retry-connector==3.2.1 +bleak-retry-connector==3.3.0 # homeassistant.components.bluetooth bleak==0.21.1 From a6f88fb1239a1345b1f1fad6367a90e796243a91 Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Thu, 26 Oct 2023 02:59:48 -0700 Subject: [PATCH 889/968] Bump screenlogicpy to v0.9.4 (#102836) --- homeassistant/components/screenlogic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index e61ca04374f..69bed1af700 100644 --- a/homeassistant/components/screenlogic/manifest.json +++ b/homeassistant/components/screenlogic/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/screenlogic", "iot_class": "local_push", "loggers": ["screenlogicpy"], - "requirements": ["screenlogicpy==0.9.3"] + "requirements": ["screenlogicpy==0.9.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index befc5e905cb..63f4f31ac5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2382,7 +2382,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34d7a6a4207..c32f38b1cf1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1769,7 +1769,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.9.3 +screenlogicpy==0.9.4 # homeassistant.components.backup securetar==2023.3.0 From 9e140864ebc5fe91c2aa3a0286ef2b895348031b Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Thu, 26 Oct 2023 19:12:18 +0900 Subject: [PATCH 890/968] Address late review of switchbot cloud (#102842) For Martin's review --- homeassistant/components/switchbot_cloud/climate.py | 8 +++++--- homeassistant/components/switchbot_cloud/switch.py | 2 -- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index 8ad0e1ad43f..803669c806d 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -14,7 +14,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -44,7 +43,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] @@ -55,7 +53,10 @@ async def async_setup_entry( class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): - """Representation of a SwitchBot air conditionner, as it is an IR device, we don't know the actual state.""" + """Representation of a SwitchBot air conditionner. + + As it is an IR device, we don't know the actual state. + """ _attr_assumed_state = True _attr_supported_features = ( @@ -116,3 +117,4 @@ class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): return await self._do_send_command(temperature=temperature) self._attr_target_temperature = temperature + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index c63b1713b8d..4f2cdc22ba9 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -7,7 +7,6 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import DiscoveryInfoType from . import SwitchbotCloudData from .const import DOMAIN @@ -19,7 +18,6 @@ async def async_setup_entry( hass: HomeAssistant, config: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] From 7e4da1d03be029996918854ee2caf5ce2878f272 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 26 Oct 2023 17:31:53 +0200 Subject: [PATCH 891/968] Bump aiowithings to 1.0.2 (#102852) --- homeassistant/components/withings/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/manifest.json b/homeassistant/components/withings/manifest.json index a1df31ceecc..d43ae7da50c 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_push", "loggers": ["aiowithings"], "quality_scale": "platinum", - "requirements": ["aiowithings==1.0.1"] + "requirements": ["aiowithings==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 63f4f31ac5e..61d80f9654e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -387,7 +387,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c32f38b1cf1..c6dca20ecd3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -362,7 +362,7 @@ aiowatttime==0.1.1 aiowebostv==0.3.3 # homeassistant.components.withings -aiowithings==1.0.1 +aiowithings==1.0.2 # homeassistant.components.yandex_transport aioymaps==1.2.2 From a490b5e2869ea2cfe1411f46c5cf2c383b5ae095 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 27 Oct 2023 12:09:59 +0200 Subject: [PATCH 892/968] Add connections to PassiveBluetoothProcessorEntity (#102854) --- .../components/bluetooth/passive_update_processor.py | 5 ++++- .../bluetooth/test_passive_update_processor.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 8138587b9b5..7dd39c14039 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast from homeassistant import config_entries from homeassistant.const import ( + ATTR_CONNECTIONS, ATTR_IDENTIFIERS, ATTR_NAME, CONF_ENTITY_CATEGORY, @@ -16,7 +17,7 @@ from homeassistant.const import ( EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_platform import async_get_current_platform from homeassistant.helpers.event import async_track_time_interval @@ -644,6 +645,8 @@ class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProce self._attr_unique_id = f"{address}-{key}" if ATTR_NAME not in self._attr_device_info: self._attr_device_info[ATTR_NAME] = self.processor.coordinator.name + if device_id is None: + self._attr_device_info[ATTR_CONNECTIONS] = {(CONNECTION_BLUETOOTH, address)} self._attr_name = processor.entity_names.get(entity_key) @property diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index 9e3f954a0c5..8cc76e01d8c 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -1208,6 +1208,7 @@ async def test_integration_with_entity_without_a_device( assert entity_one.unique_id == "aa:bb:cc:dd:ee:ff-temperature" assert entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "name": "Generic", } assert entity_one.entity_key == PassiveBluetoothEntityKey( @@ -1396,6 +1397,7 @@ async def test_integration_multiple_entity_platforms( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1412,6 +1414,7 @@ async def test_integration_multiple_entity_platforms( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1556,6 +1559,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1572,6 +1576,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1636,6 +1641,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1652,6 +1658,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1730,6 +1737,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-pressure" assert sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", @@ -1746,6 +1754,7 @@ async def test_integration_multiple_entity_platforms_with_reload_and_restart( assert binary_sensor_entity_one.unique_id == "aa:bb:cc:dd:ee:ff-motion" assert binary_sensor_entity_one.device_info == { "identifiers": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, + "connections": {("bluetooth", "aa:bb:cc:dd:ee:ff")}, "manufacturer": "Test Manufacturer", "model": "Test Model", "name": "Test Device", From 293025ab6cbf3dec5c4ce88c5a38e0bba07a1630 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 26 Oct 2023 17:26:27 +0200 Subject: [PATCH 893/968] Update frontend to 20231026.0 (#102857) --- 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 064777b4921..31f4dc14559 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==20231025.1"] + "requirements": ["home-assistant-frontend==20231026.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3aff4601d45..e99afb6330b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 61d80f9654e..3a85a24589a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6dca20ecd3..678a2eefcf7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231025.1 +home-assistant-frontend==20231026.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From cc7a4d01e316ef8a13b94b5a1aef4e5bc38c3c8d Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 27 Oct 2023 11:30:37 +0200 Subject: [PATCH 894/968] Don't return resources in safe mode (#102865) --- .../components/lovelace/websocket.py | 3 +++ tests/components/lovelace/test_resources.py | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index 423ba3117ea..c9b7cb10386 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -60,6 +60,9 @@ async def websocket_lovelace_resources( """Send Lovelace UI resources over WebSocket configuration.""" resources = hass.data[DOMAIN]["resources"] + if hass.config.safe_mode: + connection.send_result(msg["id"], []) + if not resources.loaded: await resources.async_load() resources.loaded = True diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 1e2a121d6fb..f7830f03ed6 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -185,3 +185,26 @@ async def test_storage_resources_import_invalid( "resources" in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + + +async def test_storage_resources_safe_mode( + hass: HomeAssistant, hass_ws_client, hass_storage: dict[str, Any] +) -> None: + """Test defining resources in storage config.""" + + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + hass.config.safe_mode = True + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] From 0573981d6ff7cdada85d5b41f93b3d30c567f07d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 27 Oct 2023 10:51:45 +0200 Subject: [PATCH 895/968] Fix mqtt schema import not available for mqtt_room (#102866) --- homeassistant/components/mqtt/__init__.py | 1 + homeassistant/components/mqtt_room/sensor.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1f8e5bbf2e7..ac229cb677f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -47,6 +47,7 @@ from .client import ( # noqa: F401 publish, subscribe, ) +from .config import MQTT_BASE_SCHEMA, MQTT_RO_SCHEMA, MQTT_RW_SCHEMA # noqa: F401 from .config_integration import CONFIG_SCHEMA_BASE from .const import ( # noqa: F401 ATTR_PAYLOAD, diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 4eb3a3f5171..cb0e840604e 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -47,7 +47,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, } -).extend(mqtt.config.MQTT_RO_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) @lru_cache(maxsize=256) From 62fc9dfd6c27f6c2e1fa36b231ea988f8a6e38dc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 12:25:27 +0200 Subject: [PATCH 896/968] Allow missing components in safe mode (#102888) --- homeassistant/helpers/check_config.py | 2 +- tests/helpers/test_check_config.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 3218c1e839b..4aa4e72b0bb 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -127,7 +127,7 @@ async def async_check_ha_config_file( # noqa: C901 try: integration = await async_get_integration_with_requirements(hass, domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Integration error: {domain} - {ex}") continue except RequirementsNotFound as ex: diff --git a/tests/helpers/test_check_config.py b/tests/helpers/test_check_config.py index 6af03136760..a3fd02686ac 100644 --- a/tests/helpers/test_check_config.py +++ b/tests/helpers/test_check_config.py @@ -125,6 +125,19 @@ async def test_component_not_found_recovery_mode(hass: HomeAssistant) -> None: assert not res.errors +async def test_component_not_found_safe_mode(hass: HomeAssistant) -> None: + """Test no errors if component not found in safe mode.""" + # Make sure they don't exist + files = {YAML_CONFIG_FILE: BASE_CONFIG + "beer:"} + hass.config.safe_mode = True + with patch("os.path.isfile", return_value=True), patch_yaml_files(files): + res = await async_check_ha_config_file(hass) + log_ha_config(res) + + assert res.keys() == {"homeassistant"} + assert not res.errors + + async def test_component_platform_not_found_2(hass: HomeAssistant) -> None: """Test errors if component or platform not found.""" # Make sure they don't exist From b5c75a2f2f429b7c91207f4677cf32b114fd49df Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 Oct 2023 13:26:26 +0200 Subject: [PATCH 897/968] Allow missing components in safe mode (#102891) --- homeassistant/helpers/check_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/check_config.py b/homeassistant/helpers/check_config.py index 4aa4e72b0bb..a5e68cb877d 100644 --- a/homeassistant/helpers/check_config.py +++ b/homeassistant/helpers/check_config.py @@ -216,7 +216,7 @@ async def async_check_ha_config_file( # noqa: C901 ) platform = p_integration.get_platform(domain) except loader.IntegrationNotFound as ex: - if not hass.config.recovery_mode: + if not hass.config.recovery_mode and not hass.config.safe_mode: result.add_error(f"Platform error {domain}.{p_name} - {ex}") continue except ( From 5dca3844ef1b6e422330643ac627cf3a6545d77e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 Oct 2023 13:55:22 +0200 Subject: [PATCH 898/968] Add redirect from shopping list to todo (#102894) --- homeassistant/components/frontend/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8201cbc5b7a..2ec991750f0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -388,6 +388,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Can be removed in 2023 hass.http.register_redirect("/config/server_control", "/developer-tools/yaml") + # Shopping list panel was replaced by todo panel in 2023.11 + hass.http.register_redirect("/shopping-list", "/todo") + hass.http.app.router.register_resource(IndexView(repo_path, hass)) async_register_built_in_panel(hass, "profile") From 7fe1ac901fb0b3deecbc35409020f9d718114767 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 13:28:16 +0200 Subject: [PATCH 899/968] Some textual fixes for todo (#102895) --- homeassistant/components/todo/manifest.json | 2 +- homeassistant/components/todo/services.yaml | 4 +-- homeassistant/components/todo/strings.json | 34 ++++++++++----------- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/todo/manifest.json b/homeassistant/components/todo/manifest.json index 2edf3309e32..8efc93ad4e7 100644 --- a/homeassistant/components/todo/manifest.json +++ b/homeassistant/components/todo/manifest.json @@ -1,6 +1,6 @@ { "domain": "todo", - "name": "To-do", + "name": "To-do list", "codeowners": ["@home-assistant/core"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/todo", diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index cf5f3da2b3a..c31a7e88808 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -7,7 +7,7 @@ create_item: fields: summary: required: true - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: @@ -29,7 +29,7 @@ update_item: selector: text: summary: - example: "Submit Income Tax Return" + example: "Submit income tax return" selector: text: status: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 4a5a33e94e5..623c46375f0 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -1,5 +1,5 @@ { - "title": "To-do List", + "title": "To-do list", "entity_component": { "_": { "name": "[%key:component::todo::title%]" @@ -7,48 +7,48 @@ }, "services": { "create_item": { - "name": "Create To-do List Item", - "description": "Add a new To-do List Item.", + "name": "Create to-do list item", + "description": "Add a new to-do list item.", "fields": { "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "update_item": { - "name": "Update To-do List Item", - "description": "Update an existing To-do List Item based on either its Unique Id or Summary.", + "name": "Update to-do list item", + "description": "Update an existing to-do list item based on either its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Id", - "description": "Unique Identifier for the To-do List Item." + "name": "To-do item unique ID", + "description": "Unique identifier for the to-do list item." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." }, "status": { "name": "Status", - "description": "A status or confirmation of the To-do item." + "description": "A status or confirmation of the to-do item." } } }, "delete_item": { - "name": "Delete a To-do List Item", - "description": "Delete an existing To-do List Item either by its Unique Id or Summary.", + "name": "Delete a to-do list item", + "description": "Delete an existing to-do list item either by its unique ID or summary.", "fields": { "uid": { - "name": "To-do Item Unique Ids", - "description": "Unique Identifiers for the To-do List Items." + "name": "To-do item unique IDs", + "description": "Unique identifiers for the to-do list items." }, "summary": { "name": "Summary", - "description": "The short summary that represents the To-do item." + "description": "The short summary that represents the to-do item." } } } @@ -56,7 +56,7 @@ "selector": { "status": { "options": { - "needs_action": "Needs Action", + "needs_action": "Not completed", "completed": "Completed" } } From 867aaf10ee63b5efe3bc7260724440e62e3c7007 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 27 Oct 2023 14:02:42 +0200 Subject: [PATCH 900/968] Bumped version to 2023.11.0b1 --- 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 5b8e9f43b21..39f957ef77c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 8068b7e55b7..1303d1cd7fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b0" +version = "2023.11.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 2eb2a651978f3b74f72b60960d3942638391877d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 901/968] Use new API for Vasttrafik (#102570) --- .../components/vasttrafik/manifest.json | 2 +- homeassistant/components/vasttrafik/sensor.py | 44 +++++++++++++------ requirements_all.txt | 2 +- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json index aa1907a8a23..336d06e182c 100644 --- a/homeassistant/components/vasttrafik/manifest.json +++ b/homeassistant/components/vasttrafik/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/vasttrafik", "iot_class": "cloud_polling", "loggers": ["vasttrafik"], - "requirements": ["vtjp==0.1.14"] + "requirements": ["vtjp==0.2.1"] } diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py index 711f66ea033..6a083232079 100644 --- a/homeassistant/components/vasttrafik/sensor.py +++ b/homeassistant/components/vasttrafik/sensor.py @@ -1,7 +1,7 @@ """Support for Västtrafik public transport.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging import vasttrafik @@ -22,6 +22,9 @@ ATTR_ACCESSIBILITY = "accessibility" ATTR_DIRECTION = "direction" ATTR_LINE = "line" ATTR_TRACK = "track" +ATTR_FROM = "from" +ATTR_TO = "to" +ATTR_DELAY = "delay" CONF_DEPARTURES = "departures" CONF_FROM = "from" @@ -32,7 +35,6 @@ CONF_SECRET = "secret" DEFAULT_DELAY = 0 - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -101,7 +103,7 @@ class VasttrafikDepartureSensor(SensorEntity): if location.isdecimal(): station_info = {"station_name": location, "station_id": location} else: - station_id = self._planner.location_name(location)[0]["id"] + station_id = self._planner.location_name(location)[0]["gid"] station_info = {"station_name": location, "station_id": station_id} return station_info @@ -143,20 +145,36 @@ class VasttrafikDepartureSensor(SensorEntity): self._attributes = {} else: for departure in self._departureboard: - line = departure.get("sname") - if "cancelled" in departure: + service_journey = departure.get("serviceJourney", {}) + line = service_journey.get("line", {}) + + if departure.get("isCancelled"): continue - if not self._lines or line in self._lines: - if "rtTime" in departure: - self._state = departure["rtTime"] + if not self._lines or line.get("shortName") in self._lines: + if "estimatedOtherwisePlannedTime" in departure: + try: + self._state = datetime.fromisoformat( + departure["estimatedOtherwisePlannedTime"] + ).strftime("%H:%M") + except ValueError: + self._state = departure["estimatedOtherwisePlannedTime"] else: - self._state = departure["time"] + self._state = None + + stop_point = departure.get("stopPoint", {}) params = { - ATTR_ACCESSIBILITY: departure.get("accessibility"), - ATTR_DIRECTION: departure.get("direction"), - ATTR_LINE: departure.get("sname"), - ATTR_TRACK: departure.get("track"), + ATTR_ACCESSIBILITY: "wheelChair" + if line.get("isWheelchairAccessible") + else None, + ATTR_DIRECTION: service_journey.get("direction"), + ATTR_LINE: line.get("shortName"), + ATTR_TRACK: stop_point.get("platform"), + ATTR_FROM: stop_point.get("name"), + ATTR_TO: self._heading["station_name"] + if self._heading + else "ANY", + ATTR_DELAY: self._delay.seconds // 60 % 60, } self._attributes = {k: v for k, v in params.items() if v} diff --git a/requirements_all.txt b/requirements_all.txt index 3a85a24589a..173a7f8894f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2682,7 +2682,7 @@ volvooncall==0.10.3 vsure==2.6.6 # homeassistant.components.vasttrafik -vtjp==0.1.14 +vtjp==0.2.1 # homeassistant.components.vulcan vulcan-api==2.3.0 From a60656bf29bb1db7f71ebfac50c6e94e5e2cf1d2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 902/968] Improve fitbit oauth import robustness (#102833) * Improve fitbit oauth import robustness * Improve sensor tests and remove unnecessary client check * Fix oauth client id/secret config key checks * Add executor for sync call --- homeassistant/components/fitbit/sensor.py | 71 +++++++++++++-------- tests/components/fitbit/test_config_flow.py | 59 +++++++++++++++++ tests/components/fitbit/test_sensor.py | 15 ++++- 3 files changed, 118 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 45b8ea21b0e..4885c9fa16d 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -8,6 +8,8 @@ import logging import os from typing import Any, Final, cast +from fitbit import Fitbit +from oauthlib.oauth2.rfc6749.errors import OAuth2Error import voluptuous as vol from homeassistant.components.application_credentials import ( @@ -567,34 +569,51 @@ async def async_setup_platform( if config_file is not None: _LOGGER.debug("Importing existing fitbit.conf application credentials") - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] - ), + + # Refresh the token before importing to ensure it is working and not + # expired on first initialization. + authd_client = Fitbit( + config_file[CONF_CLIENT_ID], + config_file[CONF_CLIENT_SECRET], + access_token=config_file[ATTR_ACCESS_TOKEN], + refresh_token=config_file[ATTR_REFRESH_TOKEN], + expires_at=config_file[ATTR_LAST_SAVED_AT], + refresh_cb=lambda x: None, ) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - "auth_implementation": DOMAIN, - CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], - }, - CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], - CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], - CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], - }, - ) - translation_key = "deprecated_yaml_import" - if ( - result.get("type") == FlowResultType.ABORT - and result.get("reason") == "cannot_connect" - ): + try: + await hass.async_add_executor_job(authd_client.client.refresh_token) + except OAuth2Error as err: + _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" + else: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config_file[CONF_CLIENT_ID], config_file[CONF_CLIENT_SECRET] + ), + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + "auth_implementation": DOMAIN, + CONF_TOKEN: { + ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], + "expires_at": config_file[ATTR_LAST_SAVED_AT], + }, + CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], + CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], + CONF_MONITORED_RESOURCES: config[CONF_MONITORED_RESOURCES], + }, + ) + translation_key = "deprecated_yaml_import" + if ( + result.get("type") == FlowResultType.ABORT + and result.get("reason") == "cannot_connect" + ): + translation_key = "deprecated_yaml_import_issue_cannot_connect" else: translation_key = "deprecated_yaml_no_import" diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index e6ab39aff59..152439ec19a 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -209,9 +209,17 @@ async def test_import_fitbit_config( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is imported successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + with patch( "homeassistant.components.fitbit.async_setup_entry", return_value=True ) as mock_setup: @@ -256,6 +264,12 @@ async def test_import_fitbit_config_failure_cannot_connect( ) -> None: """Test platform configuration fails to import successfully.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) requests_mock.register_uri( "GET", PROFILE_API_URL, status_code=HTTPStatus.INTERNAL_SERVER_ERROR ) @@ -273,6 +287,43 @@ async def test_import_fitbit_config_failure_cannot_connect( assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" +@pytest.mark.parametrize( + "status_code", + [ + (HTTPStatus.UNAUTHORIZED), + (HTTPStatus.INTERNAL_SERVER_ERROR), + ], +) +async def test_import_fitbit_config_cannot_refresh( + hass: HomeAssistant, + fitbit_config_setup: None, + sensor_platform_setup: Callable[[], Awaitable[bool]], + issue_registry: ir.IssueRegistry, + requests_mock: Mocker, + status_code: HTTPStatus, +) -> None: + """Test platform configuration import fails when refreshing the token.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=status_code, + json="", + ) + + with patch( + "homeassistant.components.fitbit.async_setup_entry", return_value=True + ) as mock_setup: + await sensor_platform_setup() + + assert len(mock_setup.mock_calls) == 0 + + # Verify an issue is raised that we were unable to import configuration + issue = issue_registry.issues.get((DOMAIN, "deprecated_yaml")) + assert issue + assert issue.translation_key == "deprecated_yaml_import_issue_cannot_connect" + + async def test_import_fitbit_config_already_exists( hass: HomeAssistant, config_entry: MockConfigEntry, @@ -281,9 +332,17 @@ async def test_import_fitbit_config_already_exists( fitbit_config_setup: None, sensor_platform_setup: Callable[[], Awaitable[bool]], issue_registry: ir.IssueRegistry, + requests_mock: Mocker, ) -> None: """Test that platform configuration is not imported if it already exists.""" + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + # Verify existing config entry entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 diff --git a/tests/components/fitbit/test_sensor.py b/tests/components/fitbit/test_sensor.py index b54f154d406..5421a652125 100644 --- a/tests/components/fitbit/test_sensor.py +++ b/tests/components/fitbit/test_sensor.py @@ -9,7 +9,7 @@ import pytest from requests_mock.mocker import Mocker from syrupy.assertion import SnapshotAssertion -from homeassistant.components.fitbit.const import DOMAIN +from homeassistant.components.fitbit.const import DOMAIN, OAUTH2_TOKEN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -23,6 +23,7 @@ from homeassistant.util.unit_system import ( from .conftest import ( DEVICES_API_URL, PROFILE_USER_ID, + SERVER_ACCESS_TOKEN, TIMESERIES_API_URL_FORMAT, timeseries_response, ) @@ -55,6 +56,18 @@ def platforms() -> list[str]: return [Platform.SENSOR] +@pytest.fixture(autouse=True) +def mock_token_refresh(requests_mock: Mocker) -> None: + """Test that platform configuration is imported successfully.""" + + requests_mock.register_uri( + "POST", + OAUTH2_TOKEN, + status_code=HTTPStatus.OK, + json=SERVER_ACCESS_TOKEN, + ) + + @pytest.mark.parametrize( ( "monitored_resources", From 4617c16a961c2bb47eeaf62d518c11408cda1ac5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 903/968] Update aioairzone-cloud to v0.3.1 (#102899) --- homeassistant/components/airzone_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/airzone_cloud/util.py | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json index a3c0f5e7dc0..eb959342122 100644 --- a/homeassistant/components/airzone_cloud/manifest.json +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", "iot_class": "cloud_polling", "loggers": ["aioairzone_cloud"], - "requirements": ["aioairzone-cloud==0.3.0"] + "requirements": ["aioairzone-cloud==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 173a7f8894f..3ece034b571 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -192,7 +192,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 678a2eefcf7..c89d160b4ce 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -173,7 +173,7 @@ aio-georss-gdacs==0.8 aioairq==0.2.4 # homeassistant.components.airzone_cloud -aioairzone-cloud==0.3.0 +aioairzone-cloud==0.3.1 # homeassistant.components.airzone aioairzone==0.6.9 diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py index 412f0df1337..76349d06481 100644 --- a/tests/components/airzone_cloud/util.py +++ b/tests/components/airzone_cloud/util.py @@ -101,6 +101,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone1", API_NAME: "Salon", API_TYPE: API_AZ_ZONE, @@ -111,6 +112,7 @@ GET_INSTALLATION_MOCK = { API_WS_ID: WS_ID, }, { + API_CONFIG: {}, API_DEVICE_ID: "zone2", API_NAME: "Dormitorio", API_TYPE: API_AZ_ZONE, From 3d321c5ca7ceea4a2f81fcfbb1f80ee1c1471302 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 904/968] Update frontend to 20231027.0 (#102913) --- 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 31f4dc14559..a47ef38264e 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==20231026.0"] + "requirements": ["home-assistant-frontend==20231027.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e99afb6330b..5d68cead747 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3ece034b571..2b78d878adf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c89d160b4ce..ae7c3ea23cd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231026.0 +home-assistant-frontend==20231027.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 29c99f419f70faa36eca0116c72afff215ac4485 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 905/968] Bump velbusaio to 2023.10.2 (#102919) --- 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 229ee8458c6..3c773e39e33 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "requirements": ["velbus-aio==2023.10.1"], + "requirements": ["velbus-aio==2023.10.2"], "usb": [ { "vid": "10CF", diff --git a/requirements_all.txt b/requirements_all.txt index 2b78d878adf..a3d43bb2a9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2661,7 +2661,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae7c3ea23cd..9a27453bf95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1979,7 +1979,7 @@ vallox-websocket-api==3.3.0 vehicle==2.0.0 # homeassistant.components.velbus -velbus-aio==2023.10.1 +velbus-aio==2023.10.2 # homeassistant.components.venstar venstarcolortouch==0.19 From bee63ca654164150967fa392a635ae454ab10e3a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 906/968] Hide mac address from HomeWizard Energy config entry/discovery titles (#102931) --- homeassistant/components/homewizard/config_flow.py | 14 +++++++++----- tests/components/homewizard/test_config_flow.py | 14 +++++++------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 82c808a0f13..b24b49da965 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -62,7 +62,7 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry( - title=f"{device_info.product_name} ({device_info.serial})", + title=f"{device_info.product_name}", data=user_input, ) @@ -121,14 +121,18 @@ class HomeWizardConfigFlow(ConfigFlow, domain=DOMAIN): errors = {"base": ex.error_code} else: return self.async_create_entry( - title=f"{self.discovery.product_name} ({self.discovery.serial})", + title=self.discovery.product_name, data={CONF_IP_ADDRESS: self.discovery.ip}, ) self._set_confirm_only() - self.context["title_placeholders"] = { - "name": f"{self.discovery.product_name} ({self.discovery.serial})" - } + + # We won't be adding mac/serial to the title for devices + # that users generally don't have multiple of. + name = self.discovery.product_name + if self.discovery.product_type not in ["HWE-P1", "HWE-WTR"]: + name = f"{name} ({self.discovery.serial})" + self.context["title_placeholders"] = {"name": name} return self.async_show_form( step_id="discovery_confirm", diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7c6fb0bdb0d..770496b5612 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -43,7 +43,7 @@ async def test_manual_flow_works( ) assert result["type"] == "create_entry" - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "2.2.2.2" assert len(hass.config_entries.async_entries(DOMAIN)) == 1 @@ -68,8 +68,8 @@ async def test_discovery_flow_works( properties={ "api_enabled": "1", "path": "/api/v1", - "product_name": "P1 meter", - "product_type": "HWE-P1", + "product_name": "Energy Socket", + "product_type": "HWE-SKT", "serial": "aabbccddeeff", }, ) @@ -109,11 +109,11 @@ async def test_discovery_flow_works( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "Energy Socket" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] - assert result["result"].unique_id == "HWE-P1_aabbccddeeff" + assert result["result"].unique_id == "HWE-SKT_aabbccddeeff" async def test_discovery_flow_during_onboarding( @@ -149,7 +149,7 @@ async def test_discovery_flow_during_onboarding( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] @@ -214,7 +214,7 @@ async def test_discovery_flow_during_onboarding_disabled_api( ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "P1 meter (aabbccddeeff)" + assert result["title"] == "P1 meter" assert result["data"][CONF_IP_ADDRESS] == "192.168.43.183" assert result["result"] From 1c3de76b045e23d42518eb1e7e9bbaf00580fb55 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 907/968] Move HomeWizard Energy identify button to config entity category (#102932) --- homeassistant/components/homewizard/button.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homewizard/button.py b/homeassistant/components/homewizard/button.py index 96fe1b157f8..19ffb1d6042 100644 --- a/homeassistant/components/homewizard/button.py +++ b/homeassistant/components/homewizard/button.py @@ -24,7 +24,7 @@ async def async_setup_entry( class HomeWizardIdentifyButton(HomeWizardEntity, ButtonEntity): """Representation of a identify button.""" - _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_category = EntityCategory.CONFIG _attr_device_class = ButtonDeviceClass.IDENTIFY def __init__( From 974c34e2b63c9d2380f9b11799fdb926afce3990 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 908/968] Small base entity cleanup for HomeWizard Energy entities (#102933) --- homeassistant/components/homewizard/entity.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homewizard/entity.py b/homeassistant/components/homewizard/entity.py index 51dbe9fcad3..61bf20dbbc4 100644 --- a/homeassistant/components/homewizard/entity.py +++ b/homeassistant/components/homewizard/entity.py @@ -18,17 +18,13 @@ class HomeWizardEntity(CoordinatorEntity[HWEnergyDeviceUpdateCoordinator]): """Initialize the HomeWizard entity.""" super().__init__(coordinator=coordinator) self._attr_device_info = DeviceInfo( - name=coordinator.entry.title, manufacturer="HomeWizard", sw_version=coordinator.data.device.firmware_version, model=coordinator.data.device.product_type, ) - if coordinator.data.device.serial is not None: + if (serial_number := coordinator.data.device.serial) is not None: self._attr_device_info[ATTR_CONNECTIONS] = { - (CONNECTION_NETWORK_MAC, coordinator.data.device.serial) - } - - self._attr_device_info[ATTR_IDENTIFIERS] = { - (DOMAIN, coordinator.data.device.serial) + (CONNECTION_NETWORK_MAC, serial_number) } + self._attr_device_info[ATTR_IDENTIFIERS] = {(DOMAIN, serial_number)} From f9f010643a630f73f44f4cc02c8b4f4e3cc1c853 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 909/968] Handle/extend number entity availability property in HomeWizard Energy (#102934) --- homeassistant/components/homewizard/number.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/number.py b/homeassistant/components/homewizard/number.py index d51d180edb1..07f6bb9b55f 100644 --- a/homeassistant/components/homewizard/number.py +++ b/homeassistant/components/homewizard/number.py @@ -47,13 +47,17 @@ class HWEnergyNumberEntity(HomeWizardEntity, NumberEntity): await self.coordinator.api.state_set(brightness=int(value * (255 / 100))) await self.coordinator.async_refresh() + @property + def available(self) -> bool: + """Return if entity is available.""" + return super().available and self.coordinator.data.state is not None + @property def native_value(self) -> float | None: """Return the current value.""" if ( - self.coordinator.data.state is None - or self.coordinator.data.state.brightness is None + not self.coordinator.data.state + or (brightness := self.coordinator.data.state.brightness) is None ): return None - brightness: float = self.coordinator.data.state.brightness return round(brightness * (100 / 255)) From 07e4e1379ad14e295a92bc1b4839a35d32c64b48 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:02 -0400 Subject: [PATCH 910/968] Improve diagnostic handling in HomeWizard Energy (#102935) --- .../components/homewizard/diagnostics.py | 33 +++++---- .../snapshots/test_diagnostics.ambr | 71 +++++++++++++++++++ .../components/homewizard/test_diagnostics.py | 70 ++---------------- 3 files changed, 97 insertions(+), 77 deletions(-) create mode 100644 tests/components/homewizard/snapshots/test_diagnostics.ambr diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index a8f89b67ce9..b8103f7a4cb 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -28,18 +28,23 @@ async def async_get_config_entry_diagnostics( """Return diagnostics for a config entry.""" coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - meter_data = { - "device": asdict(coordinator.data.device), - "data": asdict(coordinator.data.data), - "state": asdict(coordinator.data.state) - if coordinator.data.state is not None - else None, - "system": asdict(coordinator.data.system) - if coordinator.data.system is not None - else None, - } + state: dict[str, Any] | None = None + if coordinator.data.state: + state = asdict(coordinator.data.state) - return { - "entry": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(meter_data, TO_REDACT), - } + system: dict[str, Any] | None = None + if coordinator.data.system: + system = asdict(coordinator.data.system) + + return async_redact_data( + { + "entry": async_redact_data(entry.data, TO_REDACT), + "data": { + "device": asdict(coordinator.data.device), + "data": asdict(coordinator.data.data), + "state": state, + "system": system, + }, + }, + TO_REDACT, + ) diff --git a/tests/components/homewizard/snapshots/test_diagnostics.ambr b/tests/components/homewizard/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..5e1025a8d31 --- /dev/null +++ b/tests/components/homewizard/snapshots/test_diagnostics.ambr @@ -0,0 +1,71 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'data': dict({ + 'data': dict({ + 'active_current_l1_a': -4, + 'active_current_l2_a': 2, + 'active_current_l3_a': 0, + 'active_frequency_hz': 50, + 'active_liter_lpm': 12.345, + 'active_power_average_w': 123.0, + 'active_power_l1_w': -123, + 'active_power_l2_w': 456, + 'active_power_l3_w': 123.456, + 'active_power_w': -123, + 'active_tariff': 2, + 'active_voltage_l1_v': 230.111, + 'active_voltage_l2_v': 230.222, + 'active_voltage_l3_v': 230.333, + 'any_power_fail_count': 4, + 'external_devices': None, + 'gas_timestamp': '2021-03-14T11:22:33', + 'gas_unique_id': '**REDACTED**', + 'long_power_fail_count': 5, + 'meter_model': 'ISKRA 2M550T-101', + 'monthly_power_peak_timestamp': '2023-01-01T08:00:10', + 'monthly_power_peak_w': 1111.0, + 'smr_version': 50, + 'total_gas_m3': 1122.333, + 'total_liter_m3': 1234.567, + 'total_power_export_kwh': 13086.777, + 'total_power_export_t1_kwh': 4321.333, + 'total_power_export_t2_kwh': 8765.444, + 'total_power_export_t3_kwh': None, + 'total_power_export_t4_kwh': None, + 'total_power_import_kwh': 13779.338, + 'total_power_import_t1_kwh': 10830.511, + 'total_power_import_t2_kwh': 2948.827, + 'total_power_import_t3_kwh': None, + 'total_power_import_t4_kwh': None, + 'unique_meter_id': '**REDACTED**', + 'voltage_sag_l1_count': 1, + 'voltage_sag_l2_count': 2, + 'voltage_sag_l3_count': 3, + 'voltage_swell_l1_count': 4, + 'voltage_swell_l2_count': 5, + 'voltage_swell_l3_count': 6, + 'wifi_ssid': '**REDACTED**', + 'wifi_strength': 100, + }), + 'device': dict({ + 'api_version': 'v1', + 'firmware_version': '2.11', + 'product_name': 'P1 Meter', + 'product_type': 'HWE-SKT', + 'serial': '**REDACTED**', + }), + 'state': dict({ + 'brightness': 255, + 'power_on': True, + 'switch_lock': False, + }), + 'system': dict({ + 'cloud_enabled': True, + }), + }), + 'entry': dict({ + 'ip_address': '**REDACTED**', + }), + }) +# --- diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py index 64e8b0c6dfd..9e9797439b3 100644 --- a/tests/components/homewizard/test_diagnostics.py +++ b/tests/components/homewizard/test_diagnostics.py @@ -1,6 +1,7 @@ """Tests for diagnostics data.""" -from homeassistant.components.diagnostics import REDACTED +from syrupy.assertion import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -12,67 +13,10 @@ async def test_diagnostics( hass: HomeAssistant, hass_client: ClientSessionGenerator, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "entry": {"ip_address": REDACTED}, - "data": { - "device": { - "product_name": "P1 Meter", - "product_type": "HWE-SKT", - "serial": REDACTED, - "api_version": "v1", - "firmware_version": "2.11", - }, - "data": { - "wifi_ssid": REDACTED, - "wifi_strength": 100, - "smr_version": 50, - "meter_model": "ISKRA 2M550T-101", - "unique_meter_id": REDACTED, - "active_tariff": 2, - "total_power_import_kwh": 13779.338, - "total_power_import_t1_kwh": 10830.511, - "total_power_import_t2_kwh": 2948.827, - "total_power_import_t3_kwh": None, - "total_power_import_t4_kwh": None, - "total_power_export_kwh": 13086.777, - "total_power_export_t1_kwh": 4321.333, - "total_power_export_t2_kwh": 8765.444, - "total_power_export_t3_kwh": None, - "total_power_export_t4_kwh": None, - "active_power_w": -123, - "active_power_l1_w": -123, - "active_power_l2_w": 456, - "active_power_l3_w": 123.456, - "active_voltage_l1_v": 230.111, - "active_voltage_l2_v": 230.222, - "active_voltage_l3_v": 230.333, - "active_current_l1_a": -4, - "active_current_l2_a": 2, - "active_current_l3_a": 0, - "active_frequency_hz": 50, - "voltage_sag_l1_count": 1, - "voltage_sag_l2_count": 2, - "voltage_sag_l3_count": 3, - "voltage_swell_l1_count": 4, - "voltage_swell_l2_count": 5, - "voltage_swell_l3_count": 6, - "any_power_fail_count": 4, - "long_power_fail_count": 5, - "active_power_average_w": 123.0, - "monthly_power_peak_w": 1111.0, - "monthly_power_peak_timestamp": "2023-01-01T08:00:10", - "total_gas_m3": 1122.333, - "gas_timestamp": "2021-03-14T11:22:33", - "gas_unique_id": REDACTED, - "active_liter_lpm": 12.345, - "total_liter_m3": 1234.567, - "external_devices": None, - }, - "state": {"power_on": True, "switch_lock": False, "brightness": 255}, - "system": {"cloud_enabled": True}, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From bcddf52364d08d359ba033f986c1468c6871b920 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 911/968] Update xknxproject to 3.4.0 (#102946) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b5c98c7203a..a233ca38705 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -12,7 +12,7 @@ "quality_scale": "platinum", "requirements": [ "xknx==2.11.2", - "xknxproject==3.3.0", + "xknxproject==3.4.0", "knx-frontend==2023.6.23.191712" ] } diff --git a/requirements_all.txt b/requirements_all.txt index a3d43bb2a9b..b69dbf4c258 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2740,7 +2740,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a27453bf95..5ec473c9ef1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2043,7 +2043,7 @@ xiaomi-ble==0.21.1 xknx==2.11.2 # homeassistant.components.knx -xknxproject==3.3.0 +xknxproject==3.4.0 # homeassistant.components.bluesound # homeassistant.components.fritz From 85d999b020de586ad07eb8a0c94e9b78ac099512 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 912/968] Add gas device class to dsmr_reader sensor (#102953) DSMR reader integration - can't configure gas meter in energy dashboard posible due to missing device_class Fixes #102367 --- homeassistant/components/dsmr_reader/definitions.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 33bba375fd3..d89e30311e9 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -141,6 +141,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( translation_key="gas_meter_usage", entity_registry_enabled_default=False, icon="mdi:fire", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.TOTAL_INCREASING, ), @@ -209,6 +210,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), @@ -283,6 +285,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/day-consumption/gas", translation_key="daily_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -460,6 +463,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-month/gas", translation_key="current_month_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( @@ -538,6 +542,7 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( key="dsmr/current-year/gas", translation_key="current_year_gas_usage", icon="mdi:counter", + device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, ), DSMRReaderSensorEntityDescription( From 9c9f1ea685535606be244ea2313169146e2fbcce Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 913/968] Fix error message strings for Todoist configuration flow (#102968) * Fix error message strings for Todoist configuration flow * Update error code in test --- homeassistant/components/todoist/config_flow.py | 2 +- homeassistant/components/todoist/strings.json | 6 ++++-- tests/components/todoist/test_config_flow.py | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 6098df40ea0..b8c79210dfb 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -44,7 +44,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await api.get_tasks() except HTTPError as err: if err.response.status_code == HTTPStatus.UNAUTHORIZED: - errors["base"] = "invalid_access_token" + errors["base"] = "invalid_api_key" else: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 68c2305d073..442114eb118 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -9,10 +9,12 @@ } }, "error": { - "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py index 4175902da31..141f12269de 100644 --- a/tests/components/todoist/test_config_flow.py +++ b/tests/components/todoist/test_config_flow.py @@ -69,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: ) assert result2.get("type") == FlowResultType.FORM - assert result2.get("errors") == {"base": "invalid_access_token"} + assert result2.get("errors") == {"base": "invalid_api_key"} @pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) From 9c8a4bb4eb3aa9cc4058e039b56ce00fdab50f2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 914/968] Fix proximity zone handling (#102971) * fix proximity zone * fix test --- homeassistant/components/proximity/__init__.py | 12 ++++++------ tests/components/proximity/test_init.py | 8 ++++++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index a4520435161..07b5f931f79 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -70,22 +70,22 @@ def async_setup_proximity_component( ignored_zones: list[str] = config[CONF_IGNORED_ZONES] proximity_devices: list[str] = config[CONF_DEVICES] tolerance: int = config[CONF_TOLERANCE] - proximity_zone = name + proximity_zone = config[CONF_ZONE] unit_of_measurement: str = config.get( CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit ) - zone_id = f"zone.{config[CONF_ZONE]}" + zone_friendly_name = name proximity = Proximity( hass, - proximity_zone, + zone_friendly_name, DEFAULT_DIST_TO_ZONE, DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST, ignored_zones, proximity_devices, tolerance, - zone_id, + proximity_zone, unit_of_measurement, ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" @@ -171,7 +171,7 @@ class Proximity(Entity): devices_to_calculate = False devices_in_zone = "" - zone_state = self.hass.states.get(self.proximity_zone) + zone_state = self.hass.states.get(f"zone.{self.proximity_zone}") proximity_latitude = ( zone_state.attributes.get(ATTR_LATITUDE) if zone_state else None ) @@ -189,7 +189,7 @@ class Proximity(Entity): devices_to_calculate = True # Check the location of all devices. - if (device_state.state).lower() == (self.friendly_name).lower(): + if (device_state.state).lower() == (self.proximity_zone).lower(): device_friendly = device_state.name if devices_in_zone != "": devices_in_zone = f"{devices_in_zone}, " diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 34f87b5c261..0ec8765e604 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,7 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"devices": ["device_tracker.test1"], "tolerance": "1"}, + "work": { + "devices": ["device_tracker.test1"], + "tolerance": "1", + "zone": "work", + }, } } @@ -42,7 +46,7 @@ async def test_proximities_setup(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, - "work": {"tolerance": "1"}, + "work": {"tolerance": "1", "zone": "work"}, } } From eef318f63cd74150b3e462d4af8814478126049c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 Oct 2023 23:29:03 -0400 Subject: [PATCH 915/968] Bumped version to 2023.11.0b2 --- 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 39f957ef77c..cbc21687110 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 1303d1cd7fe..92c11a19a12 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b1" +version = "2023.11.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 59d2bce369687246a51bde8d6033ed9ba2334a2e Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 30 Oct 2023 10:29:40 +0100 Subject: [PATCH 916/968] Enable dry mode for Tado AC's V3 (#99568) --- homeassistant/components/tado/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 9366a18b6fe..d6ae50c33c1 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -119,7 +119,7 @@ TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { } # These modes will not allow a temp to be set -TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_FAN] # # HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO # This lets tado decide on a temp From a741bc9951fc25b4b5b9fa6f29bdc35fdef6b6f9 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 30 Oct 2023 10:16:41 -0400 Subject: [PATCH 917/968] Add retry before unavailable to Honeywell (#101702) Co-authored-by: Robert Resch --- homeassistant/components/honeywell/climate.py | 13 ++++++++-- homeassistant/components/honeywell/const.py | 1 + tests/components/honeywell/test_climate.py | 26 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index 63d05135d5d..ab23c878c15 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -38,6 +38,7 @@ from .const import ( CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, + RETRY, ) ATTR_FAN_ACTION = "fan_action" @@ -155,6 +156,7 @@ class HoneywellUSThermostat(ClimateEntity): self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False + self._retry = 0 self._attr_unique_id = device.deviceid @@ -483,21 +485,28 @@ class HoneywellUSThermostat(ClimateEntity): try: await self._device.refresh() self._attr_available = True + self._retry = 0 + except UnauthorizedError: try: await self._data.client.login() await self._device.refresh() self._attr_available = True + self._retry = 0 except ( SomeComfortError, ClientConnectionError, asyncio.TimeoutError, ): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except (ClientConnectionError, asyncio.TimeoutError): - self._attr_available = False + self._retry += 1 + if self._retry > RETRY: + self._attr_available = False except UnexpectedResponse: pass diff --git a/homeassistant/components/honeywell/const.py b/homeassistant/components/honeywell/const.py index d5153a69f65..32846563c44 100644 --- a/homeassistant/components/honeywell/const.py +++ b/homeassistant/components/honeywell/const.py @@ -10,3 +10,4 @@ DEFAULT_HEAT_AWAY_TEMPERATURE = 61 CONF_DEV_ID = "thermostat" CONF_LOC_ID = "location" _LOGGER = logging.getLogger(__name__) +RETRY = 3 diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 7bd76cb8522..53cb70475c9 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -28,7 +28,7 @@ from homeassistant.components.climate import ( SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.components.honeywell.climate import SCAN_INTERVAL +from homeassistant.components.honeywell.climate import RETRY, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TEMPERATURE, @@ -1083,6 +1083,17 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, @@ -1126,7 +1137,6 @@ async def test_async_update_errors( state = hass.states.get(entity_id) assert state.state == "off" - # "reload integration" test device.refresh.side_effect = aiosomecomfort.SomeComfortError client.login.side_effect = aiosomecomfort.AuthError async_fire_time_changed( @@ -1139,6 +1149,18 @@ async def test_async_update_errors( assert state.state == "off" device.refresh.side_effect = ClientConnectionError + + # Due to server instability, only mark entity unavailable after RETRY update attempts + for _ in range(RETRY): + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == "off" + async_fire_time_changed( hass, utcnow() + SCAN_INTERVAL, From 20409d0124243aefd86990c999c4e2a15333158f Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 10:18:59 -0400 Subject: [PATCH 918/968] Make Hydrawise initialize data immediately (#101936) --- .../components/hydrawise/__init__.py | 26 +++----------- .../components/hydrawise/binary_sensor.py | 11 +++--- homeassistant/components/hydrawise/entity.py | 12 +++++++ homeassistant/components/hydrawise/sensor.py | 12 +++---- homeassistant/components/hydrawise/switch.py | 10 ++---- .../hydrawise/test_binary_sensor.py | 5 --- tests/components/hydrawise/test_init.py | 34 ++++++++----------- tests/components/hydrawise/test_sensor.py | 12 ++----- tests/components/hydrawise/test_switch.py | 9 +---- 9 files changed, 45 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index bc3c62cfb9f..ddff1954eb3 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -2,7 +2,6 @@ from pydrawise import legacy -from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -13,11 +12,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN, LOGGER, SCAN_INTERVAL +from .const import DOMAIN, SCAN_INTERVAL from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( @@ -53,24 +51,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up Hydrawise from a config entry.""" access_token = config_entry.data[CONF_API_KEY] - try: - hydrawise = await hass.async_add_executor_job( - legacy.LegacyHydrawise, access_token - ) - except (ConnectTimeout, HTTPError) as ex: - LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - raise ConfigEntryNotReady( - f"Unable to connect to Hydrawise cloud service: {ex}" - ) from ex - - hass.data.setdefault(DOMAIN, {})[ - config_entry.entry_id - ] = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) - if not hydrawise.controller_info or not hydrawise.controller_status: - raise ConfigEntryNotReady("Hydrawise data not loaded") - - # NOTE: We don't need to call async_config_entry_first_refresh() because - # data is fetched when the Hydrawiser object is instantiated. + hydrawise = legacy.LegacyHydrawise(access_token, load_on_init=False) + coordinator = HydrawiseDataUpdateCoordinator(hass, hydrawise, SCAN_INTERVAL) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 30096a9bf97..1953e413672 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -12,12 +12,12 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -95,13 +95,10 @@ async def async_setup_entry( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the state.""" - LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] self._attr_is_on = relay_data["timestr"] == "Now" - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py index c3f295e1c4d..38fde322673 100644 --- a/homeassistant/components/hydrawise/entity.py +++ b/homeassistant/components/hydrawise/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -36,3 +37,14 @@ class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): name=data["name"], manufacturer=MANUFACTURER, ) + self._update_attrs() + + def _update_attrs(self) -> None: + """Update state attributes.""" + return # pragma: no cover + + @callback + def _handle_coordinator_update(self) -> None: + """Get the latest data and updates the state.""" + self._update_attrs() + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index ef98ce99bfb..369e952c1be 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -11,13 +11,13 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from .const import DOMAIN, LOGGER +from .const import DOMAIN from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -82,10 +82,8 @@ async def async_setup_entry( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - @callback - def _handle_coordinator_update(self) -> None: - """Get the latest data and updates the states.""" - LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + def _update_attrs(self) -> None: + """Update state attributes.""" relay_data = self.coordinator.api.relays_by_zone_number[self.data["relay"]] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": @@ -94,8 +92,6 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - LOGGER.debug("New cycle time: %s", next_cycle) self._attr_native_value = dt_util.utc_from_timestamp( dt_util.as_timestamp(dt_util.now()) + next_cycle ) - super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index d1ea0233145..caaefd7aa26 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -13,7 +13,7 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -23,7 +23,6 @@ from .const import ( CONF_WATERING_TIME, DEFAULT_WATERING_TIME, DOMAIN, - LOGGER, ) from .coordinator import HydrawiseDataUpdateCoordinator from .entity import HydrawiseEntity @@ -124,14 +123,11 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): elif self.entity_description.key == "auto_watering": self.coordinator.api.suspend_zone(365, zone_number) - @callback - def _handle_coordinator_update(self) -> None: - """Update device state.""" + def _update_attrs(self) -> None: + """Update state attributes.""" zone_number = self.data["relay"] - LOGGER.debug("Updating Hydrawise switch: %s", self.name) timestr = self.coordinator.api.relays_by_zone_number[zone_number]["timestr"] if self.entity_description.key == "manual_watering": self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": self._attr_is_on = timestr not in {"", "Now"} - super()._handle_coordinator_update() diff --git a/tests/components/hydrawise/test_binary_sensor.py b/tests/components/hydrawise/test_binary_sensor.py index ab88c5fb750..c60f4392f1e 100644 --- a/tests/components/hydrawise/test_binary_sensor.py +++ b/tests/components/hydrawise/test_binary_sensor.py @@ -17,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test binary_sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - connectivity = hass.states.get("binary_sensor.home_controller_connectivity") assert connectivity is not None assert connectivity.state == "on" diff --git a/tests/components/hydrawise/test_init.py b/tests/components/hydrawise/test_init.py index 87c158ec0b9..79cea94d479 100644 --- a/tests/components/hydrawise/test_init.py +++ b/tests/components/hydrawise/test_init.py @@ -1,6 +1,6 @@ """Tests for the Hydrawise integration.""" -from unittest.mock import Mock, patch +from unittest.mock import Mock from requests.exceptions import HTTPError @@ -15,6 +15,7 @@ from tests.common import MockConfigEntry async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) -> None: """Test that setup with a YAML config triggers an import and warning.""" + mock_pydrawise.update_controller_info.return_value = True mock_pydrawise.customer_id = 12345 mock_pydrawise.status = "unknown" config = {"hydrawise": {CONF_ACCESS_TOKEN: "_access-token_"}} @@ -29,29 +30,22 @@ async def test_setup_import_success(hass: HomeAssistant, mock_pydrawise: Mock) - async def test_connect_retry( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that a connection error triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.side_effect = HTTPError - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.side_effect = HTTPError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY async def test_setup_no_data( - hass: HomeAssistant, mock_config_entry: MockConfigEntry + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pydrawise: Mock ) -> None: """Test that no data from the API triggers a retry.""" - with patch("pydrawise.legacy.LegacyHydrawise") as mock_api: - mock_api.return_value.controller_info = {} - mock_api.return_value.controller_status = None - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - mock_api.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_pydrawise.update_controller_info.return_value = False + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/hydrawise/test_sensor.py b/tests/components/hydrawise/test_sensor.py index b7c60f333f4..c6d3fecab65 100644 --- a/tests/components/hydrawise/test_sensor.py +++ b/tests/components/hydrawise/test_sensor.py @@ -1,14 +1,11 @@ """Test Hydrawise sensor.""" -from datetime import timedelta - from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry @pytest.mark.freeze_time("2023-10-01 00:00:00+00:00") @@ -18,11 +15,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test sensor states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering_time1 = hass.states.get("sensor.zone_one_watering_time") assert watering_time1 is not None assert watering_time1.state == "0" @@ -33,4 +25,4 @@ async def test_states( next_cycle = hass.states.get("sensor.zone_one_next_cycle") assert next_cycle is not None - assert next_cycle.state == "2023-10-04T19:52:27+00:00" + assert next_cycle.state == "2023-10-04T19:49:57+00:00" diff --git a/tests/components/hydrawise/test_switch.py b/tests/components/hydrawise/test_switch.py index 615a336ee5f..39d789f4cf9 100644 --- a/tests/components/hydrawise/test_switch.py +++ b/tests/components/hydrawise/test_switch.py @@ -1,16 +1,14 @@ """Test Hydrawise switch.""" -from datetime import timedelta from unittest.mock import Mock from freezegun.api import FrozenDateTimeFactory -from homeassistant.components.hydrawise.const import SCAN_INTERVAL from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import MockConfigEntry async def test_states( @@ -19,11 +17,6 @@ async def test_states( freezer: FrozenDateTimeFactory, ) -> None: """Test switch states.""" - # Make the coordinator refresh data. - freezer.tick(SCAN_INTERVAL + timedelta(seconds=30)) - async_fire_time_changed(hass) - await hass.async_block_till_done() - watering1 = hass.states.get("switch.zone_one_manual_watering") assert watering1 is not None assert watering1.state == "off" From 12482216f614ea79035e2d16d5765d57f96ae851 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Mon, 30 Oct 2023 07:36:34 -0400 Subject: [PATCH 919/968] Fix Google Mail expired authorization (#102735) * Fix Google Mail expired authorization * add test * raise HomeAssistantError * handle in api module * uno mas --- .../components/google_mail/__init__.py | 14 +------- homeassistant/components/google_mail/api.py | 35 +++++++++++++++---- tests/components/google_mail/test_init.py | 7 +++- tests/components/google_mail/test_services.py | 15 ++++++-- 4 files changed, 49 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/google_mail/__init__.py b/homeassistant/components/google_mail/__init__.py index 15c4192ccf5..96639e4a547 100644 --- a/homeassistant/components/google_mail/__init__.py +++ b/homeassistant/components/google_mail/__init__.py @@ -1,12 +1,9 @@ """Support for Google Mail.""" from __future__ import annotations -from aiohttp.client_exceptions import ClientError, ClientResponseError - from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_NAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, @@ -35,16 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: implementation = await async_get_config_entry_implementation(hass, entry) session = OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth(session) - try: - await auth.check_and_refresh_token() - except ClientResponseError as err: - if 400 <= err.status < 500: - raise ConfigEntryAuthFailed( - "OAuth session is not valid, reauth required" - ) from err - raise ConfigEntryNotReady from err - except ClientError as err: - raise ConfigEntryNotReady from err + await auth.check_and_refresh_token() hass.data[DOMAIN][entry.entry_id] = auth hass.async_create_task( diff --git a/homeassistant/components/google_mail/api.py b/homeassistant/components/google_mail/api.py index ffa33deae14..10b2fec7467 100644 --- a/homeassistant/components/google_mail/api.py +++ b/homeassistant/components/google_mail/api.py @@ -1,9 +1,16 @@ """API for Google Mail bound to Home Assistant OAuth.""" +from aiohttp.client_exceptions import ClientError, ClientResponseError from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from googleapiclient.discovery import Resource, build +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_entry_oauth2_flow @@ -24,14 +31,30 @@ class AsyncConfigEntryAuth: async def check_and_refresh_token(self) -> str: """Check the token.""" - await self.oauth_session.async_ensure_token_valid() + try: + await self.oauth_session.async_ensure_token_valid() + except (RefreshError, ClientResponseError, ClientError) as ex: + if ( + self.oauth_session.config_entry.state + is ConfigEntryState.SETUP_IN_PROGRESS + ): + if isinstance(ex, ClientResponseError) and 400 <= ex.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from ex + raise ConfigEntryNotReady from ex + if ( + isinstance(ex, RefreshError) + or hasattr(ex, "status") + and ex.status == 400 + ): + self.oauth_session.config_entry.async_start_reauth( + self.oauth_session.hass + ) + raise HomeAssistantError(ex) from ex return self.access_token async def get_resource(self) -> Resource: """Get current resource.""" - try: - credentials = Credentials(await self.check_and_refresh_token()) - except RefreshError as ex: - self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) - raise ex + credentials = Credentials(await self.check_and_refresh_token()) return build("gmail", "v1", credentials=credentials) diff --git a/tests/components/google_mail/test_init.py b/tests/components/google_mail/test_init.py index a069ae0807b..4882fd10e80 100644 --- a/tests/components/google_mail/test_init.py +++ b/tests/components/google_mail/test_init.py @@ -73,8 +73,13 @@ async def test_expired_token_refresh_success( http.HTTPStatus.INTERNAL_SERVER_ERROR, ConfigEntryState.SETUP_RETRY, ), + ( + time.time() - 3600, + http.HTTPStatus.BAD_REQUEST, + ConfigEntryState.SETUP_ERROR, + ), ], - ids=["failure_requires_reauth", "transient_failure"], + ids=["failure_requires_reauth", "transient_failure", "revoked_auth"], ) async def test_expired_token_refresh_failure( hass: HomeAssistant, diff --git a/tests/components/google_mail/test_services.py b/tests/components/google_mail/test_services.py index b9fefa805e6..caa0d887dec 100644 --- a/tests/components/google_mail/test_services.py +++ b/tests/components/google_mail/test_services.py @@ -1,12 +1,14 @@ """Services tests for the Google Mail integration.""" from unittest.mock import patch +from aiohttp.client_exceptions import ClientResponseError from google.auth.exceptions import RefreshError import pytest from homeassistant import config_entries from homeassistant.components.google_mail import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .conftest import BUILD, SENSOR, TOKEN, ComponentSetup @@ -57,13 +59,22 @@ async def test_set_vacation( assert len(mock_client.mock_calls) == 5 +@pytest.mark.parametrize( + ("side_effect"), + ( + (RefreshError,), + (ClientResponseError("", (), status=400),), + ), +) async def test_reauth_trigger( - hass: HomeAssistant, setup_integration: ComponentSetup + hass: HomeAssistant, + setup_integration: ComponentSetup, + side_effect, ) -> None: """Test reauth is triggered after a refresh error during service call.""" await setup_integration() - with patch(TOKEN, side_effect=RefreshError), pytest.raises(RefreshError): + with patch(TOKEN, side_effect=side_effect), pytest.raises(HomeAssistantError): await hass.services.async_call( DOMAIN, "set_vacation", From cc3ae9e10304e217e5c48ce22f0d2860b2e8f057 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 29 Oct 2023 09:17:57 +0100 Subject: [PATCH 920/968] Correct total state_class of huisbaasje sensors (#102945) * Change all cumulative-interval sensors to TOTAL --- homeassistant/components/huisbaasje/sensor.py | 16 ++++----- tests/components/huisbaasje/test_sensor.py | 34 +++++-------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/huisbaasje/sensor.py b/homeassistant/components/huisbaasje/sensor.py index 6e3f5eaee33..b82b2b34a4b 100644 --- a/homeassistant/components/huisbaasje/sensor.py +++ b/homeassistant/components/huisbaasje/sensor.py @@ -146,7 +146,7 @@ SENSORS_INFO = [ translation_key="energy_today", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_DAY, precision=1, @@ -156,7 +156,7 @@ SENSORS_INFO = [ translation_key="energy_week", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_WEEK, precision=1, @@ -166,7 +166,7 @@ SENSORS_INFO = [ translation_key="energy_month", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_MONTH, precision=1, @@ -176,7 +176,7 @@ SENSORS_INFO = [ translation_key="energy_year", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, key=SOURCE_TYPE_ELECTRICITY, sensor_type=SENSOR_TYPE_THIS_YEAR, precision=1, @@ -197,7 +197,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_DAY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -207,7 +207,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_WEEK, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -217,7 +217,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_MONTH, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), @@ -227,7 +227,7 @@ SENSORS_INFO = [ native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, key=SOURCE_TYPE_GAS, sensor_type=SENSOR_TYPE_THIS_YEAR, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, icon="mdi:counter", precision=1, ), diff --git a/tests/components/huisbaasje/test_sensor.py b/tests/components/huisbaasje/test_sensor.py index 484dc8bac48..3f0bdae8e53 100644 --- a/tests/components/huisbaasje/test_sensor.py +++ b/tests/components/huisbaasje/test_sensor.py @@ -222,10 +222,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: energy_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENERGY ) assert energy_today.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" - assert ( - energy_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert energy_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( energy_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfEnergy.KILO_WATT_HOUR @@ -239,8 +236,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_week.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -255,8 +251,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_month.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -271,8 +266,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: ) assert energy_this_year.attributes.get(ATTR_ICON) == "mdi:lightning-bolt" assert ( - energy_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING + energy_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL ) assert ( energy_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @@ -295,10 +289,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_today.state == "1.1" assert gas_today.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_today.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_today.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_today.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_today.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -308,10 +299,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_week.state == "5.6" assert gas_this_week.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_week.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_week.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_week.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_week.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -321,10 +309,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_month.state == "39.1" assert gas_this_month.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_month.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_month.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_month.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_month.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS @@ -334,10 +319,7 @@ async def test_setup_entry(hass: HomeAssistant) -> None: assert gas_this_year.state == "116.7" assert gas_this_year.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS assert gas_this_year.attributes.get(ATTR_ICON) == "mdi:counter" - assert ( - gas_this_year.attributes.get(ATTR_STATE_CLASS) - is SensorStateClass.TOTAL_INCREASING - ) + assert gas_this_year.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.TOTAL assert ( gas_this_year.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfVolume.CUBIC_METERS From 36512f71573e18ea1204a4d87a10e727c57dd22d Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 29 Oct 2023 00:05:37 -0700 Subject: [PATCH 921/968] Bump opower to 0.0.38 (#102983) --- homeassistant/components/opower/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 88e03842504..a27d6f6f680 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.0.37"] + "requirements": ["opower==0.0.38"] } diff --git a/requirements_all.txt b/requirements_all.txt index b69dbf4c258..c2c34d359e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1394,7 +1394,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ec473c9ef1..f6005b77f3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1072,7 +1072,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.37 +opower==0.0.38 # homeassistant.components.oralb oralb-ble==0.17.6 From 5ac7e8b1ac2eb5c2217ad73b44969bcf5cd2c4e1 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Sun, 29 Oct 2023 13:28:35 +0100 Subject: [PATCH 922/968] Harden evohome against failures to retrieve high-precision temps (#102989) fix hass-logger-period --- homeassistant/components/evohome/__init__.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 2aa0cd42fe1..4b79ef3df1b 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -487,6 +487,18 @@ class EvoBroker: ) self.temps = None # these are now stale, will fall back to v2 temps + except KeyError as err: + _LOGGER.warning( + ( + "Unable to obtain high-precision temperatures. " + "It appears the JSON schema is not as expected, " + "so the high-precision feature will be disabled until next restart." + "Message is: %s" + ), + err, + ) + self.client_v1 = self.temps = None + else: if ( str(self.client_v1.location_id) @@ -495,7 +507,7 @@ class EvoBroker: _LOGGER.warning( "The v2 API's configured location doesn't match " "the v1 API's default location (there is more than one location), " - "so the high-precision feature will be disabled" + "so the high-precision feature will be disabled until next restart" ) self.client_v1 = self.temps = None else: From fefe930506814cfdf89715ca6eabe1cc376de553 Mon Sep 17 00:00:00 2001 From: Tom Puttemans Date: Sun, 29 Oct 2023 10:23:24 +0100 Subject: [PATCH 923/968] DSMR Gas currently delivered device state class conflict (#102991) Fixes #102985 --- homeassistant/components/dsmr_reader/definitions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index d89e30311e9..f12b2ad72bc 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -210,7 +210,6 @@ SENSORS: tuple[DSMRReaderSensorEntityDescription, ...] = ( DSMRReaderSensorEntityDescription( key="dsmr/consumption/gas/currently_delivered", translation_key="current_gas_usage", - device_class=SensorDeviceClass.GAS, native_unit_of_measurement=UnitOfVolume.CUBIC_METERS, state_class=SensorStateClass.MEASUREMENT, ), From e81bfb959e0e71f5c0c2fcc554eb1750847f3941 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 Oct 2023 10:43:57 +0100 Subject: [PATCH 924/968] Fix proximity entity id (#102992) * fix proximity entity id * extend test to cover entity id --- homeassistant/components/proximity/__init__.py | 2 +- tests/components/proximity/test_init.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index 07b5f931f79..23a8fc3bf64 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -88,7 +88,7 @@ def async_setup_proximity_component( proximity_zone, unit_of_measurement, ) - proximity.entity_id = f"{DOMAIN}.{proximity_zone}" + proximity.entity_id = f"{DOMAIN}.{zone_friendly_name}" proximity.async_write_ha_state() diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py index 0ec8765e604..cd96d0d7b81 100644 --- a/tests/components/proximity/test_init.py +++ b/tests/components/proximity/test_init.py @@ -13,6 +13,11 @@ async def test_proximities(hass: HomeAssistant) -> None: "devices": ["device_tracker.test1", "device_tracker.test2"], "tolerance": "1", }, + "home_test2": { + "ignored_zones": ["work"], + "devices": ["device_tracker.test1", "device_tracker.test2"], + "tolerance": "1", + }, "work": { "devices": ["device_tracker.test1"], "tolerance": "1", @@ -23,7 +28,7 @@ async def test_proximities(hass: HomeAssistant) -> None: assert await async_setup_component(hass, DOMAIN, config) - proximities = ["home", "work"] + proximities = ["home", "home_test2", "work"] for prox in proximities: state = hass.states.get(f"proximity.{prox}") From 13580a334f37586bfbfe6b2b40d77cae7fe4683f Mon Sep 17 00:00:00 2001 From: Nortonko <52453201+Nortonko@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:32:24 +0100 Subject: [PATCH 925/968] Bump python-androidtv to 0.0.73 (#102999) * Update manifest.json Bump python-androidtv to version 0.0.73 * bump androidtv 0.0.73 * bump androidtv 0.0.73 --- homeassistant/components/androidtv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index b8c020e6e1e..2d0b062c750 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -9,7 +9,7 @@ "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ "adb-shell[async]==0.4.4", - "androidtv[async]==0.0.72", + "androidtv[async]==0.0.73", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index c2c34d359e3..b248c8f1dab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -414,7 +414,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6005b77f3c..cf6c995b317 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -383,7 +383,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.72 +androidtv[async]==0.0.73 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 From 031b1c26ce428b9c4652d4f011c1f4dbf8975d41 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 30 Oct 2023 08:46:20 +0000 Subject: [PATCH 926/968] Fix utility_meter reset when DST change occurs (#103012) --- .../components/utility_meter/sensor.py | 24 ++++++++++--------- tests/components/utility_meter/test_sensor.py | 14 ++++++++++- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index cd581d8c37f..794a65db03a 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -534,16 +534,25 @@ class UtilityMeterSensor(RestoreSensor): self.async_write_ha_state() - async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger than daily cycles.""" + async def _program_reset(self): + """Program the reset of the utility meter.""" if self._cron_pattern is not None: + tz = dt_util.get_time_zone(self.hass.config.time_zone) self.async_on_remove( async_track_point_in_time( self.hass, self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), + croniter(self._cron_pattern, dt_util.now(tz)).get_next( + datetime + ), # we need timezone for DST purposes (see issue #102984) ) ) + + async def _async_reset_meter(self, event): + """Reset the utility meter status.""" + + await self._program_reset() + await self.async_reset_meter(self._tariff_entity) async def async_reset_meter(self, entity_id): @@ -566,14 +575,7 @@ class UtilityMeterSensor(RestoreSensor): """Handle entity which will be added.""" await super().async_added_to_hass() - if self._cron_pattern is not None: - self.async_on_remove( - async_track_point_in_time( - self.hass, - self._async_reset_meter, - croniter(self._cron_pattern, dt_util.now()).get_next(datetime), - ) - ) + await self._program_reset() self.async_on_remove( async_dispatcher_connect( diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 43d68d87362..2c64338c4f3 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1266,7 +1266,9 @@ async def _test_self_reset( state = hass.states.get("sensor.energy_bill") if expect_reset: assert state.attributes.get("last_period") == "2" - assert state.attributes.get("last_reset") == now.isoformat() + assert ( + state.attributes.get("last_reset") == dt_util.as_utc(now).isoformat() + ) # last_reset is kept in UTC assert state.state == "3" else: assert state.attributes.get("last_period") == "0" @@ -1348,6 +1350,16 @@ async def test_self_reset_hourly(hass: HomeAssistant) -> None: ) +async def test_self_reset_hourly_dst(hass: HomeAssistant) -> None: + """Test hourly reset of meter in DST change conditions.""" + + hass.config.time_zone = "Europe/Lisbon" + dt_util.set_default_time_zone(dt_util.get_time_zone(hass.config.time_zone)) + await _test_self_reset( + hass, gen_config("hourly"), "2023-10-29T01:59:00.000000+00:00" + ) + + async def test_self_reset_daily(hass: HomeAssistant) -> None: """Test daily reset of meter.""" await _test_self_reset( From 70e89781234df8cc7049a8d76d708e740364d489 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 Oct 2023 12:44:15 -0400 Subject: [PATCH 927/968] Fix zwave_js siren name (#103016) * Fix zwave_js.siren name * Fix test --- homeassistant/components/zwave_js/entity.py | 2 +- homeassistant/components/zwave_js/siren.py | 2 ++ tests/components/zwave_js/test_siren.py | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 0b9c68e9664..e7e110e7db6 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -206,7 +206,7 @@ class ZWaveBaseEntity(Entity): ): name += f" ({primary_value.endpoint})" - return name + return name.strip() @property def available(self) -> bool: diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 6de6b0f4e45..7df88f7dca4 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -72,6 +72,8 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): if self._attr_available_tones: self._attr_supported_features |= SirenEntityFeature.TONES + self._attr_name = self.generate_name(include_value_name=True) + @property def is_on(self) -> bool | None: """Return whether device is on.""" diff --git a/tests/components/zwave_js/test_siren.py b/tests/components/zwave_js/test_siren.py index 210339e22d7..6df5881107a 100644 --- a/tests/components/zwave_js/test_siren.py +++ b/tests/components/zwave_js/test_siren.py @@ -9,7 +9,7 @@ from homeassistant.components.siren import ( from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant -SIREN_ENTITY = "siren.indoor_siren_6_2" +SIREN_ENTITY = "siren.indoor_siren_6_play_tone_2" TONE_ID_VALUE_ID = { "endpoint": 2, From f70c13214cae287f91be7f8c79432eb53274b393 Mon Sep 17 00:00:00 2001 From: kpine Date: Sun, 29 Oct 2023 11:15:19 -0700 Subject: [PATCH 928/968] Revert "Fix temperature setting for multi-setpoint z-wave device (#102395)" (#103022) This reverts commit 2d6dc2bcccff7518366655a67947d73506fc1e50. --- homeassistant/components/zwave_js/climate.py | 8 +- tests/components/zwave_js/conftest.py | 14 - .../climate_intermatic_pe653_state.json | 4508 ----------------- tests/components/zwave_js/test_climate.py | 193 - 4 files changed, 3 insertions(+), 4720 deletions(-) delete mode 100644 tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 28084eecfa6..d511a030fb1 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -259,11 +259,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): def _current_mode_setpoint_enums(self) -> list[ThermostatSetpointType]: """Return the list of enums that are relevant to the current thermostat mode.""" if self._current_mode is None or self._current_mode.value is None: - # Thermostat with no support for setting a mode is just a setpoint - if self.info.primary_value.property_key is None: - return [] - return [ThermostatSetpointType(int(self.info.primary_value.property_key))] - + # Thermostat(valve) with no support for setting a mode + # is considered heating-only + return [ThermostatSetpointType.HEATING] return THERMOSTAT_MODE_SETPOINT_MAP.get(int(self._current_mode.value), []) @property diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 534f2fd2457..5a424b38c5b 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -662,12 +662,6 @@ def logic_group_zdb5100_state_fixture(): return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) -@pytest.fixture(name="climate_intermatic_pe653_state", scope="session") -def climate_intermatic_pe653_state_fixture(): - """Load Intermatic PE653 Pool Control node state fixture data.""" - return json.loads(load_fixture("zwave_js/climate_intermatic_pe653_state.json")) - - @pytest.fixture(name="central_scene_node_state", scope="session") def central_scene_node_state_fixture(): """Load node with Central Scene CC node state fixture data.""" @@ -1312,14 +1306,6 @@ def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): return node -@pytest.fixture(name="climate_intermatic_pe653") -def climate_intermatic_pe653_fixture(client, climate_intermatic_pe653_state): - """Mock an Intermatic PE653 node.""" - node = Node(client, copy.deepcopy(climate_intermatic_pe653_state)) - client.driver.controller.nodes[node.node_id] = node - return node - - @pytest.fixture(name="central_scene_node") def central_scene_node_fixture(client, central_scene_node_state): """Mock a node with the Central Scene CC.""" diff --git a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json b/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json deleted file mode 100644 index a5e86b9c013..00000000000 --- a/tests/components/zwave_js/fixtures/climate_intermatic_pe653_state.json +++ /dev/null @@ -1,4508 +0,0 @@ -{ - "nodeId": 19, - "index": 0, - "status": 4, - "ready": true, - "isListening": true, - "isRouting": true, - "isSecure": false, - "manufacturerId": 5, - "productId": 1619, - "productType": 20549, - "firmwareVersion": "3.9", - "deviceConfig": { - "filename": "/data/db/devices/0x0005/pe653.json", - "isEmbedded": true, - "manufacturer": "Intermatic", - "manufacturerId": 5, - "label": "PE653", - "description": "Pool Control", - "devices": [ - { - "productType": 20549, - "productId": 1619 - } - ], - "firmwareVersion": { - "min": "0.0", - "max": "255.255" - }, - "preferred": false, - "associations": {}, - "paramInformation": { - "_map": {} - }, - "compat": { - "addCCs": {}, - "overrideQueries": { - "overrides": {} - } - } - }, - "label": "PE653", - "endpointCountIsDynamic": false, - "endpointsHaveIdenticalCapabilities": false, - "individualEndpointCount": 39, - "aggregatedEndpointCount": 0, - "interviewAttempts": 1, - "endpoints": [ - { - "nodeId": 19, - "index": 0, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 96, - "name": "Multi Channel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 145, - "name": "Manufacturer Proprietary", - "version": 1, - "isSecure": false - }, - { - "id": 115, - "name": "Powerlevel", - "version": 1, - "isSecure": false - }, - { - "id": 112, - "name": "Configuration", - "version": 1, - "isSecure": false - }, - { - "id": 67, - "name": "Thermostat Setpoint", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 48, - "name": "Binary Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 114, - "name": "Manufacturer Specific", - "version": 1, - "isSecure": false - }, - { - "id": 134, - "name": "Version", - "version": 1, - "isSecure": false - }, - { - "id": 129, - "name": "Clock", - "version": 1, - "isSecure": false - }, - { - "id": 133, - "name": "Association", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 2, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 3, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 4, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 5, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - }, - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 6, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 7, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 8, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 9, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 10, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 11, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 12, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 13, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 14, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 15, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 16, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 17, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 18, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 19, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 20, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 21, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 22, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 23, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 49, - "name": "Multilevel Sensor", - "version": 1, - "isSecure": false - }, - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 24, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 25, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 26, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 27, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 28, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 29, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 30, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 31, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 32, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 33, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 34, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 35, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 36, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 37, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 38, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - }, - { - "nodeId": 19, - "index": 39, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "commandClasses": [ - { - "id": 37, - "name": "Binary Switch", - "version": 1, - "isSecure": false - } - ] - } - ], - "values": [ - { - "endpoint": 0, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 7, - "propertyName": "setpoint", - "propertyKeyName": "Furnace", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Furnace)", - "ccSpecific": { - "setpointType": 7 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 60 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 2, - "propertyName": "Installed Pump Type", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Installed Pump Type", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "One Speed", - "1": "Two Speed" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Installed Pump Type" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 1, - "propertyName": "Booster (Cleaner) Pump Installed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Booster (Cleaner) Pump Installed", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Installed" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 1, - "propertyKey": 65280, - "propertyName": "Booster (Cleaner) Pump Operation Mode", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Set the filter pump mode to use when the booster (cleaner) pump is running.", - "label": "Booster (Cleaner) Pump Operation Mode", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disable", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 2, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Booster (Cleaner) Pump Operation Mode", - "info": "Set the filter pump mode to use when the booster (cleaner) pump is running." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 65280, - "propertyName": "Heater Cooldown Period", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Heater Cooldown Period", - "default": -1, - "min": -1, - "max": 15, - "states": { - "0": "Heater installed with no cooldown", - "-1": "No heater installed" - }, - "unit": "minutes", - "valueSize": 2, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Heater Cooldown Period" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 2, - "propertyKey": 1, - "propertyName": "Heater Safety Setting", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Prevent the heater from turning on while the pump is off.", - "label": "Heater Safety Setting", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 2, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Heater Safety Setting", - "info": "Prevent the heater from turning on while the pump is off." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 4278190080, - "propertyName": "Water Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Water Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Water Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 16711680, - "propertyName": "Air/Freeze Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Air/Freeze Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Air/Freeze Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 3, - "propertyKey": 65280, - "propertyName": "Solar Temperature Offset", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Solar Temperature Offset", - "default": 0, - "min": -5, - "max": 5, - "unit": "°F", - "valueSize": 4, - "format": 0, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Solar Temperature Offset" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 22, - "propertyName": "Pool/Spa Configuration", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Pool/Spa Configuration", - "default": 0, - "min": 0, - "max": 2, - "states": { - "0": "Pool", - "1": "Spa", - "2": "Both" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Pool/Spa Configuration" - }, - "value": 2 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 23, - "propertyName": "Spa Mode Pump Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration.", - "label": "Spa Mode Pump Speed", - "default": 1, - "min": 1, - "max": 6, - "states": { - "1": "Disabled", - "2": "Circuit 1", - "3": "VSP Speed 1", - "4": "VSP Speed 2", - "5": "VSP Speed 3", - "6": "VSP Speed 4" - }, - "valueSize": 1, - "format": 0, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Spa Mode Pump Speed", - "info": "Requires pool/spa configuration." - }, - "value": 1 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 32, - "propertyName": "Variable Speed Pump - Speed 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 1", - "default": 750, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 1", - "info": "Requires connected variable speed pump." - }, - "value": 1400 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 33, - "propertyName": "Variable Speed Pump - Speed 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 2", - "default": 1500, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 2", - "info": "Requires connected variable speed pump." - }, - "value": 1700 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 34, - "propertyName": "Variable Speed Pump - Speed 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 3", - "default": 2350, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 3", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 35, - "propertyName": "Variable Speed Pump - Speed 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Speed 4", - "default": 3110, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Speed 4", - "info": "Requires connected variable speed pump." - }, - "value": 2500 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 49, - "propertyName": "Variable Speed Pump - Max Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires connected variable speed pump.", - "label": "Variable Speed Pump - Max Speed", - "default": 3450, - "min": 400, - "max": 3450, - "unit": "RPM", - "valueSize": 2, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump - Max Speed", - "info": "Requires connected variable speed pump." - }, - "value": 3000 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 4278190080, - "propertyName": "Freeze Protection: Temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Temperature", - "default": 0, - "min": 0, - "max": 44, - "states": { - "0": "Disabled", - "40": "40 °F", - "41": "41 °F", - "42": "42 °F", - "43": "43 °F", - "44": "44 °F" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Temperature" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65536, - "propertyName": "Freeze Protection: Turn On Circuit 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 1", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 1" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 131072, - "propertyName": "Freeze Protection: Turn On Circuit 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 2", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 2" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 262144, - "propertyName": "Freeze Protection: Turn On Circuit 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 3", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 3" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 524288, - "propertyName": "Freeze Protection: Turn On Circuit 4", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 4", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 4" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 1048576, - "propertyName": "Freeze Protection: Turn On Circuit 5", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Freeze Protection: Turn On Circuit 5", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Circuit 5" - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 65280, - "propertyName": "Freeze Protection: Turn On VSP Speed", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires variable speed pump and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On VSP Speed", - "default": 0, - "min": 0, - "max": 5, - "states": { - "0": "None", - "2": "VSP Speed 1", - "3": "VSP Speed 2", - "4": "VSP Speed 3", - "5": "VSP Speed 4" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On VSP Speed", - "info": "Requires variable speed pump and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 128, - "propertyName": "Freeze Protection: Turn On Heater", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires heater and connected air/freeze sensor.", - "label": "Freeze Protection: Turn On Heater", - "default": 0, - "min": 0, - "max": 1, - "states": { - "0": "Disable", - "1": "Enable" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": false, - "isFromConfig": true, - "name": "Freeze Protection: Turn On Heater", - "info": "Requires heater and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 50, - "propertyKey": 127, - "propertyName": "Freeze Protection: Pool/Spa Cycle Time", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Requires pool/spa configuration and connected air/freeze sensor.", - "label": "Freeze Protection: Pool/Spa Cycle Time", - "default": 0, - "min": 0, - "max": 30, - "unit": "minutes", - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Freeze Protection: Pool/Spa Cycle Time", - "info": "Requires pool/spa configuration and connected air/freeze sensor." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 4, - "propertyName": "Circuit 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable.", - "label": "Circuit 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 1", - "info": "Start time (first two bytes, little endian) and stop time (last two bytes, little endian) of schedule in minutes past midnight, e.g. 12:05am (0x0500) to 3:00pm (0x8403) is entered as 83919875. Set to 4294967295 (0xffffffff) to disable." - }, - "value": 1979884035 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 5, - "propertyName": "Circuit 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 6, - "propertyName": "Circuit 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 7, - "propertyName": "Circuit 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 8, - "propertyName": "Circuit 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 9, - "propertyName": "Circuit 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 10, - "propertyName": "Circuit 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 11, - "propertyName": "Circuit 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 12, - "propertyName": "Circuit 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 13, - "propertyName": "Circuit 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 14, - "propertyName": "Circuit 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 15, - "propertyName": "Circuit 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 16, - "propertyName": "Circuit 5 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 17, - "propertyName": "Circuit 5 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 18, - "propertyName": "Circuit 5 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Circuit 5 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Circuit 5 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 19, - "propertyName": "Pool/Spa Mode Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 20, - "propertyName": "Pool/Spa Mode Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 0 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 21, - "propertyName": "Pool/Spa Mode Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Pool/Spa Mode Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Pool/Spa Mode Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 36, - "propertyName": "Variable Speed Pump Speed 1 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 37, - "propertyName": "Variable Speed Pump Speed 1 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 38, - "propertyName": "Variable Speed Pump Speed 1 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 1 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 1 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 39, - "propertyName": "Variable Speed Pump Speed 2 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 1476575235 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 40, - "propertyName": "Variable Speed Pump Speed 2 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 41, - "propertyName": "Variable Speed Pump Speed 2 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 2 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 2 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 42, - "propertyName": "Variable Speed Pump Speed 3 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 43, - "propertyName": "Variable Speed Pump Speed 3 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 44, - "propertyName": "Variable Speed Pump Speed 3 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 3 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 3 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 45, - "propertyName": "Variable Speed Pump Speed 4 Schedule 1", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 1", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 1", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 46, - "propertyName": "Variable Speed Pump Speed 4 Schedule 2", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 2", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 2", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 112, - "commandClassName": "Configuration", - "property": 47, - "propertyName": "Variable Speed Pump Speed 4 Schedule 3", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "description": "Refer to parameter 4 for usage.", - "label": "Variable Speed Pump Speed 4 Schedule 3", - "default": 4294967295, - "min": 0, - "max": 4294967295, - "states": { - "4294967295": "Disabled" - }, - "valueSize": 4, - "format": 1, - "allowManualEntry": true, - "isFromConfig": true, - "name": "Variable Speed Pump Speed 4 Schedule 3", - "info": "Refer to parameter 4 for usage." - }, - "value": 4294967295 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "manufacturerId", - "propertyName": "manufacturerId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Manufacturer ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 5 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productType", - "propertyName": "productType", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product type", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 20549 - }, - { - "endpoint": 0, - "commandClass": 114, - "commandClassName": "Manufacturer Specific", - "property": "productId", - "propertyName": "productId", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Product ID", - "min": 0, - "max": 65535, - "stateful": true, - "secret": false - }, - "value": 1619 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "libraryType", - "propertyName": "libraryType", - "ccVersion": 1, - "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": 6 - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "protocolVersion", - "propertyName": "protocolVersion", - "ccVersion": 1, - "metadata": { - "type": "string", - "readable": true, - "writeable": false, - "label": "Z-Wave protocol version", - "stateful": true, - "secret": false - }, - "value": "2.78" - }, - { - "endpoint": 0, - "commandClass": 134, - "commandClassName": "Version", - "property": "firmwareVersions", - "propertyName": "firmwareVersions", - "ccVersion": 1, - "metadata": { - "type": "string[]", - "readable": true, - "writeable": false, - "label": "Z-Wave chip firmware versions", - "stateful": true, - "secret": false - }, - "value": ["3.9"] - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 1, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 48, - "commandClassName": "Binary Sensor", - "property": "Any", - "propertyName": "Any", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Sensor state (Any)", - "ccSpecific": { - "sensorType": 255 - }, - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 1, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 81, - "nodeId": 19 - }, - { - "endpoint": 1, - "commandClass": 67, - "commandClassName": "Thermostat Setpoint", - "property": "setpoint", - "propertyKey": 1, - "propertyName": "setpoint", - "propertyKeyName": "Heating", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": true, - "label": "Setpoint (Heating)", - "ccSpecific": { - "setpointType": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 39 - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 2, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 2, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 84, - "nodeId": 19 - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 3, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 86, - "nodeId": 19 - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 4, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 80, - "nodeId": 19 - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 5, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 5, - "commandClass": 49, - "commandClassName": "Multilevel Sensor", - "property": "Air temperature", - "propertyName": "Air temperature", - "ccVersion": 1, - "metadata": { - "type": "number", - "readable": true, - "writeable": false, - "label": "Air temperature", - "ccSpecific": { - "sensorType": 1, - "scale": 1 - }, - "unit": "°F", - "stateful": true, - "secret": false - }, - "value": 83, - "nodeId": 19 - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 6, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 7, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 8, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 9, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 10, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 11, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 12, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 13, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 14, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 15, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 16, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 17, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 18, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 19, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 20, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 21, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 22, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 23, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 24, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 25, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 26, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 27, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 28, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 29, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 30, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - } - }, - { - "endpoint": 31, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 32, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 33, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 34, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 35, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 36, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 37, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": false - }, - { - "endpoint": 38, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "currentValue", - "propertyName": "currentValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": false, - "label": "Current value", - "stateful": true, - "secret": false - }, - "value": true - }, - { - "endpoint": 39, - "commandClass": 37, - "commandClassName": "Binary Switch", - "property": "targetValue", - "propertyName": "targetValue", - "ccVersion": 1, - "metadata": { - "type": "boolean", - "readable": true, - "writeable": true, - "label": "Target value", - "valueChangeOptions": ["transitionDuration"], - "stateful": true, - "secret": false - } - } - ], - "isFrequentListening": false, - "maxDataRate": 40000, - "supportedDataRates": [40000], - "protocolVersion": 2, - "supportsBeaming": true, - "supportsSecurity": false, - "nodeType": 1, - "deviceClass": { - "basic": { - "key": 4, - "label": "Routing Slave" - }, - "generic": { - "key": 16, - "label": "Binary Switch" - }, - "specific": { - "key": 1, - "label": "Binary Power Switch" - }, - "mandatorySupportedCCs": [32, 37, 39], - "mandatoryControlledCCs": [] - }, - "interviewStage": "Complete", - "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0005:0x5045:0x0653:3.9", - "highestSecurityClass": -1, - "isControllerNode": false, - "keepAwake": false -} diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index cdc1e9959a7..e9040dfd397 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -792,196 +792,3 @@ async def test_thermostat_raise_repair_issue_and_warning_when_setting_fan_preset "Dry and Fan preset modes are deprecated and will be removed in Home Assistant 2024.2. Please use the corresponding Dry and Fan HVAC modes instead" in caplog.text ) - - -async def test_multi_setpoint_thermostat( - hass: HomeAssistant, client, climate_intermatic_pe653, integration -) -> None: - """Test a thermostat with multiple setpoints.""" - node = climate_intermatic_pe653 - - heating_entity_id = "climate.pool_control_2" - heating = hass.states.get(heating_entity_id) - assert heating - assert heating.state == HVACMode.HEAT - assert heating.attributes[ATTR_TEMPERATURE] == 3.9 - assert heating.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - heating.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - furnace_entity_id = "climate.pool_control" - furnace = hass.states.get(furnace_entity_id) - assert furnace - assert furnace.state == HVACMode.HEAT - assert furnace.attributes[ATTR_TEMPERATURE] == 15.6 - assert furnace.attributes[ATTR_HVAC_MODES] == [HVACMode.HEAT] - assert ( - furnace.attributes[ATTR_SUPPORTED_FEATURES] - == ClimateEntityFeature.TARGET_TEMPERATURE - ) - - client.async_send_command_no_wait.reset_mock() - - # Test setting temperature of heating setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_TEMPERATURE: 20.0, - }, - blocking=True, - ) - - # Test setting temperature of furnace setpoint - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_TEMPERATURE: 2.0, - }, - blocking=True, - ) - - # Test setting illegal mode raises an error - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.COOL, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: heating_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - # this is a no-op since there's no mode - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_HVAC_MODE, - { - ATTR_ENTITY_ID: furnace_entity_id, - ATTR_HVAC_MODE: HVACMode.HEAT, - }, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 2 - args = client.async_send_command.call_args_list[0][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 1, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 1, - } - assert args["value"] == 68.0 - - args = client.async_send_command.call_args_list[1][0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 19 - assert args["valueId"] == { - "endpoint": 0, - "commandClass": 67, - "property": "setpoint", - "propertyKey": 7, - } - assert args["value"] == 35.6 - - client.async_send_command.reset_mock() - - # Test heating setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 1, - "property": "setpoint", - "propertyKey": 1, - "propertyKeyName": "Heating", - "propertyName": "setpoint", - "newValue": 23, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - # furnace not changed - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 15.6 - - client.async_send_command.reset_mock() - - # Test furnace setpoint value update from value updated event - event = Event( - type="value updated", - data={ - "source": "node", - "event": "value updated", - "nodeId": 19, - "args": { - "commandClassName": "Thermostat Setpoint", - "commandClass": 67, - "endpoint": 0, - "property": "setpoint", - "propertyKey": 7, - "propertyKeyName": "Furnace", - "propertyName": "setpoint", - "newValue": 68, - "prevValue": 21.5, - }, - }, - ) - node.receive_event(event) - - # heating not changed - state = hass.states.get(heating_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == -5 - - state = hass.states.get(furnace_entity_id) - assert state - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == 20 - - client.async_send_command.reset_mock() From f5b3661836cffa5e6b4c94e78018b27eda4309a0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 14:26:10 -0700 Subject: [PATCH 929/968] Fix bug in fitbit credential import for expired tokens (#103024) * Fix bug in fitbit credential import on token refresh * Use stable test ids * Update homeassistant/components/fitbit/sensor.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/fitbit/sensor.py | 11 +++++++---- tests/components/fitbit/conftest.py | 5 +++-- tests/components/fitbit/test_config_flow.py | 18 +++++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4885c9fa16d..d0d939ce67e 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -581,7 +581,9 @@ async def async_setup_platform( refresh_cb=lambda x: None, ) try: - await hass.async_add_executor_job(authd_client.client.refresh_token) + updated_token = await hass.async_add_executor_job( + authd_client.client.refresh_token + ) except OAuth2Error as err: _LOGGER.debug("Unable to import fitbit OAuth2 credentials: %s", err) translation_key = "deprecated_yaml_import_issue_cannot_connect" @@ -599,9 +601,10 @@ async def async_setup_platform( data={ "auth_implementation": DOMAIN, CONF_TOKEN: { - ATTR_ACCESS_TOKEN: config_file[ATTR_ACCESS_TOKEN], - ATTR_REFRESH_TOKEN: config_file[ATTR_REFRESH_TOKEN], - "expires_at": config_file[ATTR_LAST_SAVED_AT], + ATTR_ACCESS_TOKEN: updated_token[ATTR_ACCESS_TOKEN], + ATTR_REFRESH_TOKEN: updated_token[ATTR_REFRESH_TOKEN], + "expires_at": updated_token["expires_at"], + "scope": " ".join(updated_token.get("scope", [])), }, CONF_CLOCK_FORMAT: config[CONF_CLOCK_FORMAT], CONF_UNIT_SYSTEM: config[CONF_UNIT_SYSTEM], diff --git a/tests/components/fitbit/conftest.py b/tests/components/fitbit/conftest.py index 155e5499543..682fb0edd3b 100644 --- a/tests/components/fitbit/conftest.py +++ b/tests/components/fitbit/conftest.py @@ -41,10 +41,11 @@ TIMESERIES_API_URL_FORMAT = ( # These constants differ from values in the config entry or fitbit.conf SERVER_ACCESS_TOKEN = { - "refresh_token": "server-access-token", - "access_token": "server-refresh-token", + "refresh_token": "server-refresh-token", + "access_token": "server-access-token", "type": "Bearer", "expires_in": 60, + "scope": " ".join(OAUTH_SCOPES), } diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index 152439ec19a..cf2d5d17f22 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -2,6 +2,7 @@ from collections.abc import Awaitable, Callable from http import HTTPStatus +import time from typing import Any from unittest.mock import patch @@ -16,9 +17,7 @@ from homeassistant.helpers import config_entry_oauth2_flow, issue_registry as ir from .conftest import ( CLIENT_ID, - FAKE_ACCESS_TOKEN, FAKE_AUTH_IMPL, - FAKE_REFRESH_TOKEN, PROFILE_API_URL, PROFILE_USER_ID, SERVER_ACCESS_TOKEN, @@ -204,6 +203,11 @@ async def test_config_entry_already_exists( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 86400, time.time() - 86400], + ids=("token_active", "token_expired"), +) async def test_import_fitbit_config( hass: HomeAssistant, fitbit_config_setup: None, @@ -235,16 +239,20 @@ async def test_import_fitbit_config( assert config_entry.unique_id == PROFILE_USER_ID data = dict(config_entry.data) + # Verify imported values from fitbit.conf and configuration.yaml and + # that the token is updated. assert "token" in data + expires_at = data["token"]["expires_at"] + assert expires_at > time.time() del data["token"]["expires_at"] - # Verify imported values from fitbit.conf and configuration.yaml assert dict(config_entry.data) == { "auth_implementation": DOMAIN, "clock_format": "24H", "monitored_resources": ["activities/steps"], "token": { - "access_token": FAKE_ACCESS_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, + "access_token": "server-access-token", + "refresh_token": "server-refresh-token", + "scope": "activity heartrate nutrition profile settings sleep weight", }, "unit_system": "default", } From 6f73d2aac5a79a78fb8c4f652ca66b059c0826f7 Mon Sep 17 00:00:00 2001 From: G-Two <7310260+G-Two@users.noreply.github.com> Date: Mon, 30 Oct 2023 04:46:48 -0400 Subject: [PATCH 930/968] Bump to subarulink 0.7.8 (#103033) --- homeassistant/components/subaru/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/subaru/manifest.json b/homeassistant/components/subaru/manifest.json index 9fae6ca9f73..0c4367c77c8 100644 --- a/homeassistant/components/subaru/manifest.json +++ b/homeassistant/components/subaru/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/subaru", "iot_class": "cloud_polling", "loggers": ["stdiomask", "subarulink"], - "requirements": ["subarulink==0.7.6"] + "requirements": ["subarulink==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index b248c8f1dab..74749f3eafe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2512,7 +2512,7 @@ streamlabswater==1.0.1 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf6c995b317..1c2a04d2b51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1872,7 +1872,7 @@ stookwijzer==1.3.0 stringcase==1.2.0 # homeassistant.components.subaru -subarulink==0.7.6 +subarulink==0.7.8 # homeassistant.components.solarlog sunwatcher==0.2.1 From 483671bf9f03aa3411f010886d40dbcb297f3132 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 29 Oct 2023 15:48:01 -0700 Subject: [PATCH 931/968] Bump google-nest-sdm to 3.0.3 (#103035) --- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index bf24fc4a4e9..89244642207 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -20,5 +20,5 @@ "iot_class": "cloud_push", "loggers": ["google_nest_sdm"], "quality_scale": "platinum", - "requirements": ["google-nest-sdm==3.0.2"] + "requirements": ["google-nest-sdm==3.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 74749f3eafe..dc5ed080b1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -910,7 +910,7 @@ google-cloud-texttospeech==2.12.3 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c2a04d2b51..c0d89daaa42 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -726,7 +726,7 @@ google-cloud-pubsub==2.13.11 google-generativeai==0.1.0 # homeassistant.components.nest -google-nest-sdm==3.0.2 +google-nest-sdm==3.0.3 # homeassistant.components.google_travel_time googlemaps==2.5.1 From 5c16a8247addf7c81498674f9089af312d7592e4 Mon Sep 17 00:00:00 2001 From: Jirka Date: Mon, 30 Oct 2023 09:54:46 +0100 Subject: [PATCH 932/968] Update MQTT QoS description string (#103036) Update strings.json --- homeassistant/components/mqtt/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 68fa39bfdc9..6197e580b1d 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -181,7 +181,7 @@ }, "qos": { "name": "QoS", - "description": "Quality of Service to use. O. At most once. 1: At least once. 2: Exactly once." + "description": "Quality of Service to use. 0: At most once. 1: At least once. 2: Exactly once." }, "retain": { "name": "Retain", From 891ad0b1be2c56f3b0c4dbc353149a19ae8cf60a Mon Sep 17 00:00:00 2001 From: Jack Boswell Date: Mon, 30 Oct 2023 20:56:50 +1300 Subject: [PATCH 933/968] Bump starlink-grpc-core to 1.1.3 (#103043) --- homeassistant/components/starlink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/starlink/manifest.json b/homeassistant/components/starlink/manifest.json index c719afa968d..b8733dd2435 100644 --- a/homeassistant/components/starlink/manifest.json +++ b/homeassistant/components/starlink/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/starlink", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["starlink-grpc-core==1.1.2"] + "requirements": ["starlink-grpc-core==1.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index dc5ed080b1b..f9fa0cdc2b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2488,7 +2488,7 @@ starline==0.1.5 starlingbank==3.2 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0d89daaa42..2c944ecf02c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1851,7 +1851,7 @@ srpenergy==1.3.6 starline==0.1.5 # homeassistant.components.starlink -starlink-grpc-core==1.1.2 +starlink-grpc-core==1.1.3 # homeassistant.components.statsd statsd==3.2.1 From f113d9aa713b7a93cf3dc7a6b1a3e482c2072632 Mon Sep 17 00:00:00 2001 From: Mike Woudenberg Date: Mon, 30 Oct 2023 15:57:00 +0100 Subject: [PATCH 934/968] Use correct config entry field to update when IP changes in loqed (#103051) --- homeassistant/components/loqed/config_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/loqed/config_flow.py b/homeassistant/components/loqed/config_flow.py index 911ccb0ff5b..1c76f480529 100644 --- a/homeassistant/components/loqed/config_flow.py +++ b/homeassistant/components/loqed/config_flow.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import webhook from homeassistant.components.zeroconf import ZeroconfServiceInfo -from homeassistant.const import CONF_API_TOKEN, CONF_HOST, CONF_NAME, CONF_WEBHOOK_ID +from homeassistant.const import CONF_API_TOKEN, CONF_NAME, CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError @@ -95,7 +95,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if already exists await self.async_set_unique_id(lock_data["bridge_mac_wifi"]) - self._abort_if_unique_id_configured({CONF_HOST: host}) + self._abort_if_unique_id_configured({"bridge_ip": host}) return await self.async_step_user() From 31d8f4b35df7d4406ed68e0a2a5c5d3dd31ca799 Mon Sep 17 00:00:00 2001 From: tronikos Date: Mon, 30 Oct 2023 08:08:51 -0700 Subject: [PATCH 935/968] Fix Opower not refreshing statistics when there are no forecast entities (#103058) Ensure _insert_statistics is periodically called --- homeassistant/components/opower/coordinator.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 5ce35e949af..239f23e7523 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -23,7 +23,7 @@ from homeassistant.components.recorder.statistics import ( statistics_during_period, ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, UnitOfEnergy, UnitOfVolume -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import aiohttp_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -58,6 +58,16 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): entry_data.get(CONF_TOTP_SECRET), ) + @callback + def _dummy_listener() -> None: + pass + + # Force the coordinator to periodically update by registering at least one listener. + # Needed when the _async_update_data below returns {} for utilities that don't provide + # forecast, which results to no sensors added, no registered listeners, and thus + # _async_update_data not periodically getting called which is needed for _insert_statistics. + self.async_add_listener(_dummy_listener) + async def _async_update_data( self, ) -> dict[str, Forecast]: @@ -71,6 +81,8 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, Forecast]]): raise ConfigEntryAuthFailed from err forecasts: list[Forecast] = await self.api.async_get_forecast() _LOGGER.debug("Updating sensor data with: %s", forecasts) + # Because Opower provides historical usage/cost with a delay of a couple of days + # we need to insert data into statistics. await self._insert_statistics() return {forecast.account.utility_account_id: forecast for forecast in forecasts} From 3728f3da69348ac035d5b07039504145f37751dd Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Mon, 30 Oct 2023 18:47:33 +0100 Subject: [PATCH 936/968] Update PyViCare to v2.28.1 for ViCare integration (#103064) --- homeassistant/components/vicare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json index 418172975d8..e8bc4178073 100644 --- a/homeassistant/components/vicare/manifest.json +++ b/homeassistant/components/vicare/manifest.json @@ -11,5 +11,5 @@ "documentation": "https://www.home-assistant.io/integrations/vicare", "iot_class": "cloud_polling", "loggers": ["PyViCare"], - "requirements": ["PyViCare==2.25.0"] + "requirements": ["PyViCare==2.28.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index f9fa0cdc2b6..dcbe0859038 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -113,7 +113,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2c944ecf02c..4715e175534 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,7 +100,7 @@ PyTransportNSW==0.1.1 PyTurboJPEG==1.7.1 # homeassistant.components.vicare -PyViCare==2.25.0 +PyViCare==2.28.1 # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.14.3 From c7b702f3c2ae38a97d52be9415a9bb9e6929aed2 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 30 Oct 2023 09:57:24 -0400 Subject: [PATCH 937/968] Bump pyschlage to 2023.10.0 (#103065) --- homeassistant/components/schlage/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/schlage/manifest.json b/homeassistant/components/schlage/manifest.json index 3568692c6ca..f474f739904 100644 --- a/homeassistant/components/schlage/manifest.json +++ b/homeassistant/components/schlage/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/schlage", "iot_class": "cloud_polling", - "requirements": ["pyschlage==2023.9.1"] + "requirements": ["pyschlage==2023.10.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index dcbe0859038..34d110f4a3d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2004,7 +2004,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4715e175534..1328f70970e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1511,7 +1511,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.9.1 +pyschlage==2023.10.0 # homeassistant.components.sensibo pysensibo==1.0.35 From bac39f0061bbf4c840dbb16f1d9725938daa76f9 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 19:40:27 +0100 Subject: [PATCH 938/968] Show a warning when no Withings data found (#103066) --- homeassistant/components/withings/sensor.py | 6 ++++++ tests/components/withings/test_sensor.py | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 1bef72c48ec..707059a2930 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -40,6 +40,7 @@ from homeassistant.util import dt as dt_util from . import WithingsData from .const import ( DOMAIN, + LOGGER, SCORE_POINTS, UOM_BEATS_PER_MINUTE, UOM_BREATHS_PER_MINUTE, @@ -787,6 +788,11 @@ async def async_setup_entry( _async_add_workout_entities ) + if not entities: + LOGGER.warning( + "No data found for Withings entry %s, sensors will be added when new data is available" + ) + async_add_entities(entities) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 0bf6b323146..5d42ace495b 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import AsyncMock, patch +from aiowithings import Goals from freezegun.api import FrozenDateTimeFactory import pytest from syrupy import SnapshotAssertion @@ -341,3 +342,20 @@ async def test_workout_sensors_created_when_receive_workout_data( await hass.async_block_till_done() assert hass.states.get("sensor.henk_last_workout_type") + + +async def test_warning_if_no_entities_created( + hass: HomeAssistant, + withings: AsyncMock, + polling_config_entry: MockConfigEntry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test we log a warning if no entities are created at startup.""" + withings.get_workouts_in_period.return_value = [] + withings.get_goals.return_value = Goals(None, None, None) + withings.get_measurement_in_period.return_value = [] + withings.get_sleep_summary_since.return_value = [] + withings.get_activities_since.return_value = [] + await setup_integration(hass, polling_config_entry, False) + + assert "No data found for Withings entry" in caplog.text From 8d781ff0638b8d6a6c7e76cd6eeef59b7e8786b0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 30 Oct 2023 15:47:08 +0100 Subject: [PATCH 939/968] Add 2 properties to Withings diagnostics (#103067) --- homeassistant/components/withings/diagnostics.py | 2 ++ tests/components/withings/snapshots/test_diagnostics.ambr | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index 7ed9f6ce2c9..31c9ffef569 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -33,4 +33,6 @@ async def async_get_config_entry_diagnostics( "webhooks_connected": withings_data.measurement_coordinator.webhooks_connected, "received_measurements": list(withings_data.measurement_coordinator.data), "received_sleep_data": withings_data.sleep_coordinator.data is not None, + "received_workout_data": withings_data.workout_coordinator.data is not None, + "received_activity_data": withings_data.activity_coordinator.data is not None, } diff --git a/tests/components/withings/snapshots/test_diagnostics.ambr b/tests/components/withings/snapshots/test_diagnostics.ambr index 3b6a5390bd6..f9b4a1d9bba 100644 --- a/tests/components/withings/snapshots/test_diagnostics.ambr +++ b/tests/components/withings/snapshots/test_diagnostics.ambr @@ -3,6 +3,7 @@ dict({ 'has_cloudhooks': True, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -26,6 +27,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- @@ -33,6 +35,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': False, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -56,6 +59,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': False, }) # --- @@ -63,6 +67,7 @@ dict({ 'has_cloudhooks': False, 'has_valid_external_webhook_url': True, + 'received_activity_data': False, 'received_measurements': list([ 1, 8, @@ -86,6 +91,7 @@ 169, ]), 'received_sleep_data': True, + 'received_workout_data': True, 'webhooks_connected': True, }) # --- From a3ebfaebe7c928d9c8771b1f547ac9a61d0e3672 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 Oct 2023 19:59:32 +0100 Subject: [PATCH 940/968] Bumped version to 2023.11.0b3 --- 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 cbc21687110..4587f1d37a1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 92c11a19a12..d6070c019ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b2" +version = "2023.11.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 06f27e7e74f5a96754162d8c79b2eca92fa757b0 Mon Sep 17 00:00:00 2001 From: Paul Manzotti Date: Tue, 31 Oct 2023 07:09:03 +0000 Subject: [PATCH 941/968] Update geniushub-client to v0.7.1 (#103071) --- homeassistant/components/geniushub/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 4029023bb07..28079293821 100644 --- a/homeassistant/components/geniushub/manifest.json +++ b/homeassistant/components/geniushub/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/geniushub", "iot_class": "local_polling", "loggers": ["geniushubclient"], - "requirements": ["geniushub-client==0.7.0"] + "requirements": ["geniushub-client==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 34d110f4a3d..51697865b8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -857,7 +857,7 @@ gassist-text==0.0.10 gcal-sync==5.0.0 # homeassistant.components.geniushub -geniushub-client==0.7.0 +geniushub-client==0.7.1 # homeassistant.components.geocaching geocachingapi==0.2.1 From 41500cbe9b8f9eb36df9664bc5030960b559cba3 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 31 Oct 2023 11:49:03 +0400 Subject: [PATCH 942/968] Code cleanup for transmission integration (#103078) --- homeassistant/components/transmission/__init__.py | 12 +----------- homeassistant/components/transmission/config_flow.py | 9 +++------ homeassistant/components/transmission/coordinator.py | 8 ++++---- homeassistant/components/transmission/strings.json | 2 -- 4 files changed, 8 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 7d019935e6c..df78c5d96aa 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -1,7 +1,6 @@ """Support for the Transmission BitTorrent client API.""" from __future__ import annotations -from datetime import timedelta from functools import partial import logging import re @@ -22,7 +21,6 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_USERNAME, Platform, ) @@ -69,7 +67,7 @@ MIGRATION_NAME_TO_KEY = { SERVICE_BASE_SCHEMA = vol.Schema( { - vol.Exclusive(CONF_ENTRY_ID, "identifier"): selector.ConfigEntrySelector(), + vol.Required(CONF_ENTRY_ID): selector.ConfigEntrySelector(), } ) @@ -135,7 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - config_entry.add_update_listener(async_options_updated) async def add_torrent(service: ServiceCall) -> None: """Add new torrent to download.""" @@ -244,10 +241,3 @@ async def get_api( except TransmissionError as error: _LOGGER.error(error) raise UnknownError from error - - -async def async_options_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Triggered by config entry options updates.""" - coordinator: TransmissionDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - coordinator.update_interval = timedelta(seconds=entry.options[CONF_SCAN_INTERVAL]) - await coordinator.async_request_refresh() diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d16981add87..a987233fef0 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -55,12 +55,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - for entry in self._async_current_entries(): - if ( - entry.data[CONF_HOST] == user_input[CONF_HOST] - and entry.data[CONF_PORT] == user_input[CONF_PORT] - ): - return self.async_abort(reason="already_configured") + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await get_api(self.hass, user_input) diff --git a/homeassistant/components/transmission/coordinator.py b/homeassistant/components/transmission/coordinator.py index 9df509b9783..91597d0e43d 100644 --- a/homeassistant/components/transmission/coordinator.py +++ b/homeassistant/components/transmission/coordinator.py @@ -71,13 +71,13 @@ class TransmissionDataUpdateCoordinator(DataUpdateCoordinator[SessionStats]): data = self.api.session_stats() self.torrents = self.api.get_torrents() self._session = self.api.get_session() - - self.check_completed_torrent() - self.check_started_torrent() - self.check_removed_torrent() except transmission_rpc.TransmissionError as err: raise UpdateFailed("Unable to connect to Transmission client") from err + self.check_completed_torrent() + self.check_started_torrent() + self.check_removed_torrent() + return data def init_torrent_list(self) -> None: diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 81d94b9aac4..77ffd6a8b2a 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -30,9 +30,7 @@ "options": { "step": { "init": { - "title": "Configure options for Transmission", "data": { - "scan_interval": "Update frequency", "limit": "Limit", "order": "Order" } From 376a79eb42427423590478a63d0a30e51c2cfd40 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 30 Oct 2023 21:43:24 +0100 Subject: [PATCH 943/968] Refactor todo services and their schema (#103079) --- homeassistant/components/todo/__init__.py | 86 +++++------ homeassistant/components/todo/services.yaml | 27 ++-- homeassistant/components/todo/strings.json | 46 +++--- tests/components/todo/test_init.py | 152 +++++++++++--------- 4 files changed, 149 insertions(+), 162 deletions(-) diff --git a/homeassistant/components/todo/__init__.py b/homeassistant/components/todo/__init__.py index 12eac858f75..968256ce3d9 100644 --- a/homeassistant/components/todo/__init__.py +++ b/homeassistant/components/todo/__init__.py @@ -43,14 +43,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: websocket_api.async_register_command(hass, websocket_handle_todo_item_move) component.async_register_entity_service( - "create_item", + "add_item", { - vol.Required("summary"): vol.All(cv.string, vol.Length(min=1)), - vol.Optional("status", default=TodoItemStatus.NEEDS_ACTION): vol.In( - {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} - ), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), }, - _async_create_todo_item, + _async_add_todo_item, required_features=[TodoListEntityFeature.CREATE_TODO_ITEM], ) component.async_register_entity_service( @@ -58,30 +55,26 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: vol.All( cv.make_entity_service_schema( { - vol.Optional("uid"): cv.string, - vol.Optional("summary"): vol.All(cv.string, vol.Length(min=1)), + vol.Required("item"): vol.All(cv.string, vol.Length(min=1)), + vol.Optional("rename"): vol.All(cv.string, vol.Length(min=1)), vol.Optional("status"): vol.In( {TodoItemStatus.NEEDS_ACTION, TodoItemStatus.COMPLETED} ), } ), - cv.has_at_least_one_key("uid", "summary"), + cv.has_at_least_one_key("rename", "status"), ), _async_update_todo_item, required_features=[TodoListEntityFeature.UPDATE_TODO_ITEM], ) component.async_register_entity_service( - "delete_item", - vol.All( - cv.make_entity_service_schema( - { - vol.Optional("uid"): vol.All(cv.ensure_list, [cv.string]), - vol.Optional("summary"): vol.All(cv.ensure_list, [cv.string]), - } - ), - cv.has_at_least_one_key("uid", "summary"), + "remove_item", + cv.make_entity_service_schema( + { + vol.Required("item"): vol.All(cv.ensure_list, [cv.string]), + } ), - _async_delete_todo_items, + _async_remove_todo_items, required_features=[TodoListEntityFeature.DELETE_TODO_ITEM], ) @@ -114,13 +107,6 @@ class TodoItem: status: TodoItemStatus | None = None """A status or confirmation of the To-do item.""" - @classmethod - def from_dict(cls, obj: dict[str, Any]) -> "TodoItem": - """Create a To-do Item from a dictionary parsed by schema validators.""" - return cls( - summary=obj.get("summary"), status=obj.get("status"), uid=obj.get("uid") - ) - class TodoListEntity(Entity): """An entity that represents a To-do list.""" @@ -232,39 +218,43 @@ async def websocket_handle_todo_item_move( connection.send_result(msg["id"]) -def _find_by_summary(summary: str, items: list[TodoItem] | None) -> TodoItem | None: - """Find a To-do List item by summary name.""" +def _find_by_uid_or_summary( + value: str, items: list[TodoItem] | None +) -> TodoItem | None: + """Find a To-do List item by uid or summary name.""" for item in items or (): - if item.summary == summary: + if value in (item.uid, item.summary): return item return None -async def _async_create_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: +async def _async_add_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Add an item to the To-do list.""" - await entity.async_create_todo_item(item=TodoItem.from_dict(call.data)) + await entity.async_create_todo_item( + item=TodoItem(summary=call.data["item"], status=TodoItemStatus.NEEDS_ACTION) + ) async def _async_update_todo_item(entity: TodoListEntity, call: ServiceCall) -> None: """Update an item in the To-do list.""" - item = TodoItem.from_dict(call.data) - if not item.uid: - found = _find_by_summary(call.data["summary"], entity.todo_items) - if not found: - raise ValueError(f"Unable to find To-do item with summary '{item.summary}'") - item.uid = found.uid + item = call.data["item"] + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found: + raise ValueError(f"Unable to find To-do item '{item}'") - await entity.async_update_todo_item(item=item) + update_item = TodoItem( + uid=found.uid, summary=call.data.get("rename"), status=call.data.get("status") + ) + + await entity.async_update_todo_item(item=update_item) -async def _async_delete_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: - """Delete an item in the To-do list.""" - uids = call.data.get("uid", []) - if not uids: - summaries = call.data.get("summary", []) - for summary in summaries: - item = _find_by_summary(summary, entity.todo_items) - if not item: - raise ValueError(f"Unable to find To-do item with summary '{summary}") - uids.append(item.uid) +async def _async_remove_todo_items(entity: TodoListEntity, call: ServiceCall) -> None: + """Remove an item in the To-do list.""" + uids = [] + for item in call.data.get("item", []): + found = _find_by_uid_or_summary(item, entity.todo_items) + if not found or not found.uid: + raise ValueError(f"Unable to find To-do item '{item}") + uids.append(found.uid) await entity.async_delete_todo_items(uids=uids) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index c31a7e88808..4d6237760ca 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -1,23 +1,15 @@ -create_item: +add_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.CREATE_TODO_ITEM fields: - summary: + item: required: true example: "Submit income tax return" selector: text: - status: - example: "needs_action" - selector: - select: - translation_key: status - options: - - needs_action - - completed update_item: target: entity: @@ -25,11 +17,13 @@ update_item: supported_features: - todo.TodoListEntityFeature.UPDATE_TODO_ITEM fields: - uid: + item: + required: true + example: "Submit income tax return" selector: text: - summary: - example: "Submit income tax return" + rename: + example: "Something else" selector: text: status: @@ -40,16 +34,13 @@ update_item: options: - needs_action - completed -delete_item: +remove_item: target: entity: domain: todo supported_features: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: - uid: - selector: - object: - summary: + item: selector: object: diff --git a/homeassistant/components/todo/strings.json b/homeassistant/components/todo/strings.json index 623c46375f0..6ba8aaba1a5 100644 --- a/homeassistant/components/todo/strings.json +++ b/homeassistant/components/todo/strings.json @@ -6,49 +6,41 @@ } }, "services": { - "create_item": { - "name": "Create to-do list item", + "add_item": { + "name": "Add to-do list item", "description": "Add a new to-do list item.", "fields": { - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." - }, - "status": { - "name": "Status", - "description": "A status or confirmation of the to-do item." + "item": { + "name": "Item name", + "description": "The name that represents the to-do item." } } }, "update_item": { "name": "Update to-do list item", - "description": "Update an existing to-do list item based on either its unique ID or summary.", + "description": "Update an existing to-do list item based on its name.", "fields": { - "uid": { - "name": "To-do item unique ID", - "description": "Unique identifier for the to-do list item." + "item": { + "name": "Item name", + "description": "The name for the to-do list item." }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "rename": { + "name": "Rename item", + "description": "The new name of the to-do item" }, "status": { - "name": "Status", + "name": "Set status", "description": "A status or confirmation of the to-do item." } } }, - "delete_item": { - "name": "Delete a to-do list item", - "description": "Delete an existing to-do list item either by its unique ID or summary.", + "remove_item": { + "name": "Remove a to-do list item", + "description": "Remove an existing to-do list item by its name.", "fields": { - "uid": { - "name": "To-do item unique IDs", - "description": "Unique identifiers for the to-do list items." - }, - "summary": { - "name": "Summary", - "description": "The short summary that represents the to-do item." + "item": { + "name": "Item name", + "description": "The name for the to-do list items." } } } diff --git a/tests/components/todo/test_init.py b/tests/components/todo/test_init.py index f4d671ad352..3e84049efa8 100644 --- a/tests/components/todo/test_init.py +++ b/tests/components/todo/test_init.py @@ -197,28 +197,18 @@ async def test_unsupported_websocket( assert resp.get("error", {}).get("code") == "not_found" -@pytest.mark.parametrize( - ("item_data", "expected_status"), - [ - ({}, TodoItemStatus.NEEDS_ACTION), - ({"status": "needs_action"}, TodoItemStatus.NEEDS_ACTION), - ({"status": "completed"}, TodoItemStatus.COMPLETED), - ], -) -async def test_create_item_service( +async def test_add_item_service( hass: HomeAssistant, - item_data: dict[str, Any], - expected_status: TodoItemStatus, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list.""" + """Test adding an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", **item_data}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -229,14 +219,14 @@ async def test_create_item_service( assert item assert item.uid is None assert item.summary == "New item" - assert item.status == expected_status + assert item.status == TodoItemStatus.NEEDS_ACTION -async def test_create_item_service_raises( +async def test_add_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test creating an item in a To-do list that raises an error.""" + """Test adding an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -244,8 +234,8 @@ async def test_create_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "create_item", - {"summary": "New item", "status": "needs_action"}, + "add_item", + {"item": "New item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -255,27 +245,23 @@ async def test_create_item_service_raises( ("item_data", "expected_error"), [ ({}, "required key not provided"), - ({"status": "needs_action"}, "required key not provided"), - ( - {"summary": "", "status": "needs_action"}, - "length of value must be at least 1", - ), + ({"item": ""}, "length of value must be at least 1"), ], ) -async def test_create_item_service_invalid_input( +async def test_add_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, item_data: dict[str, Any], expected_error: str, ) -> None: - """Test invalid input to the create item service.""" + """Test invalid input to the add item service.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(vol.Invalid, match=expected_error): await hass.services.async_call( DOMAIN, - "create_item", + "add_item", item_data, target={"entity_id": "todo.entity1"}, blocking=True, @@ -293,7 +279,7 @@ async def test_update_todo_item_service_by_id( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -302,7 +288,7 @@ async def test_update_todo_item_service_by_id( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status == TodoItemStatus.COMPLETED @@ -318,7 +304,7 @@ async def test_update_todo_item_service_by_id_status_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "status": "completed"}, + {"item": "1", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -327,12 +313,12 @@ async def test_update_todo_item_service_by_id_status_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary is None assert item.status == TodoItemStatus.COMPLETED -async def test_update_todo_item_service_by_id_summary_only( +async def test_update_todo_item_service_by_id_rename( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: @@ -343,7 +329,7 @@ async def test_update_todo_item_service_by_id_summary_only( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item"}, + {"item": "1", "rename": "Updated item"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -352,7 +338,7 @@ async def test_update_todo_item_service_by_id_summary_only( assert args item = args.kwargs.get("item") assert item - assert item.uid == "item-1" + assert item.uid == "1" assert item.summary == "Updated item" assert item.status is None @@ -368,7 +354,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -378,7 +364,7 @@ async def test_update_todo_item_service_raises( await hass.services.async_call( DOMAIN, "update_item", - {"uid": "item-1", "summary": "Updated item", "status": "completed"}, + {"item": "1", "rename": "Updated item", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -395,7 +381,7 @@ async def test_update_todo_item_service_by_summary( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #1", "status": "completed"}, + {"item": "Item #1", "rename": "Something else", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -405,10 +391,35 @@ async def test_update_todo_item_service_by_summary( item = args.kwargs.get("item") assert item assert item.uid == "1" - assert item.summary == "Item #1" + assert item.summary == "Something else" assert item.status == TodoItemStatus.COMPLETED +async def test_update_todo_item_service_by_summary_only_status( + hass: HomeAssistant, + test_entity: TodoListEntity, +) -> None: + """Test updating an item in a To-do list by summary.""" + + await create_mock_platform(hass, [test_entity]) + + await hass.services.async_call( + DOMAIN, + "update_item", + {"item": "Item #1", "rename": "Something else"}, + target={"entity_id": "todo.entity1"}, + blocking=True, + ) + + args = test_entity.async_update_todo_item.call_args + assert args + item = args.kwargs.get("item") + assert item + assert item.uid == "1" + assert item.summary == "Something else" + assert item.status is None + + async def test_update_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, @@ -421,7 +432,7 @@ async def test_update_todo_item_service_by_summary_not_found( await hass.services.async_call( DOMAIN, "update_item", - {"summary": "Item #7", "status": "completed"}, + {"item": "Item #7", "status": "completed"}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -430,10 +441,11 @@ async def test_update_todo_item_service_by_summary_not_found( @pytest.mark.parametrize( ("item_data", "expected_error"), [ - ({}, "must contain at least one of"), - ({"status": "needs_action"}, "must contain at least one of"), + ({}, r"required key not provided @ data\['item'\]"), + ({"status": "needs_action"}, r"required key not provided @ data\['item'\]"), + ({"item": "Item #1"}, "must contain at least one of"), ( - {"summary": "", "status": "needs_action"}, + {"item": "", "status": "needs_action"}, "length of value must be at least 1", ), ], @@ -458,32 +470,32 @@ async def test_update_item_service_invalid_input( ) -async def test_delete_todo_item_service_by_id( +async def test_remove_todo_item_service_by_id( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list.""" + """Test removing an item in a To-do list.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) args = test_entity.async_delete_todo_items.call_args assert args - assert args.kwargs.get("uids") == ["item-1", "item-2"] + assert args.kwargs.get("uids") == ["1", "2"] -async def test_delete_todo_item_service_raises( +async def test_remove_todo_item_service_raises( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list that raises an error.""" + """Test removing an item in a To-do list that raises an error.""" await create_mock_platform(hass, [test_entity]) @@ -491,43 +503,45 @@ async def test_delete_todo_item_service_raises( with pytest.raises(HomeAssistantError, match="Ooops"): await hass.services.async_call( DOMAIN, - "delete_item", - {"uid": ["item-1", "item-2"]}, + "remove_item", + {"item": ["1", "2"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_invalid_input( +async def test_remove_todo_item_service_invalid_input( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test invalid input to the delete item service.""" + """Test invalid input to the remove item service.""" await create_mock_platform(hass, [test_entity]) - with pytest.raises(vol.Invalid, match="must contain at least one of"): + with pytest.raises( + vol.Invalid, match=r"required key not provided @ data\['item'\]" + ): await hass.services.async_call( DOMAIN, - "delete_item", + "remove_item", {}, target={"entity_id": "todo.entity1"}, blocking=True, ) -async def test_delete_todo_item_service_by_summary( +async def test_remove_todo_item_service_by_summary( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary.""" + """Test removing an item in a To-do list by summary.""" await create_mock_platform(hass, [test_entity]) await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #1"]}, + "remove_item", + {"item": ["Item #1"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -537,19 +551,19 @@ async def test_delete_todo_item_service_by_summary( assert args.kwargs.get("uids") == ["1"] -async def test_delete_todo_item_service_by_summary_not_found( +async def test_remove_todo_item_service_by_summary_not_found( hass: HomeAssistant, test_entity: TodoListEntity, ) -> None: - """Test deleting an item in a To-do list by summary which is not found.""" + """Test removing an item in a To-do list by summary which is not found.""" await create_mock_platform(hass, [test_entity]) with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( DOMAIN, - "delete_item", - {"summary": ["Item #7"]}, + "remove_item", + {"item": ["Item #7"]}, target={"entity_id": "todo.entity1"}, blocking=True, ) @@ -656,22 +670,22 @@ async def test_move_todo_item_service_invalid_input( ("service_name", "payload"), [ ( - "create_item", + "add_item", { - "summary": "New item", + "item": "New item", }, ), ( - "delete_item", + "remove_item", { - "uid": ["1"], + "item": ["1"], }, ), ( "update_item", { - "uid": "1", - "summary": "Updated item", + "item": "1", + "rename": "Updated item", }, ), ], From 67edb98e5908ac0d6e9de3c96729c3e6eca69413 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 31 Oct 2023 08:31:53 +0100 Subject: [PATCH 944/968] Fix Met Device Info (#103082) --- homeassistant/components/met/__init__.py | 12 +++++++++++ homeassistant/components/met/weather.py | 14 ++++++------- tests/components/met/test_init.py | 26 ++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index 16bfc93f715..53764252043 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -68,6 +69,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await cleanup_old_device(hass) + return True @@ -88,6 +91,15 @@ async def async_update_entry(hass: HomeAssistant, config_entry: ConfigEntry): await hass.config_entries.async_reload(config_entry.entry_id) +async def cleanup_old_device(hass: HomeAssistant) -> None: + """Cleanup device without proper device identifier.""" + device_reg = dr.async_get(hass) + device = device_reg.async_get_device(identifiers={(DOMAIN,)}) # type: ignore[arg-type] + if device: + _LOGGER.debug("Removing improper device %s", device.name) + device_reg.async_remove_device(device.id) + + class CannotConnect(HomeAssistantError): """Unable to connect to the web site.""" diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index def06634f42..8a5c405c1c1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -60,7 +60,7 @@ async def async_setup_entry( if TYPE_CHECKING: assert isinstance(name, str) - entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] + entities = [MetWeather(coordinator, config_entry, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -69,9 +69,7 @@ async def async_setup_entry( _calculate_unique_id(config_entry.data, True), ): name = f"{name} hourly" - entities.append( - MetWeather(coordinator, config_entry.data, True, name, is_metric) - ) + entities.append(MetWeather(coordinator, config_entry, True, name, is_metric)) async_add_entities(entities) @@ -114,22 +112,22 @@ class MetWeather(SingleCoordinatorWeatherEntity[MetDataUpdateCoordinator]): def __init__( self, coordinator: MetDataUpdateCoordinator, - config: MappingProxyType[str, Any], + config_entry: ConfigEntry, hourly: bool, name: str, is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) - self._attr_unique_id = _calculate_unique_id(config, hourly) - self._config = config + self._attr_unique_id = _calculate_unique_id(config_entry.data, hourly) + self._config = config_entry.data self._is_metric = is_metric self._hourly = hourly self._attr_entity_registry_enabled_default = not hourly self._attr_device_info = DeviceInfo( name="Forecast", entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] + identifiers={(DOMAIN, config_entry.entry_id)}, manufacturer="Met.no", model="Forecast", configuration_url="https://www.met.no/en", diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py index d9085f8251f..652763947df 100644 --- a/tests/components/met/test_init.py +++ b/tests/components/met/test_init.py @@ -9,6 +9,7 @@ from homeassistant.components.met.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -48,3 +49,28 @@ async def test_fail_default_home_entry( "Skip setting up met.no integration; No Home location has been set" in caplog.text ) + + +async def test_removing_incorrect_devices( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_weather +) -> None: + """Test we remove incorrect devices.""" + entry = await init_integration(hass) + + device_reg = dr.async_get(hass) + device_reg.async_get_or_create( + config_entry_id=entry.entry_id, + name="Forecast_legacy", + entry_type=dr.DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + + assert await hass.config_entries.async_reload(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert not device_reg.async_get_device(identifiers={(DOMAIN,)}) + assert device_reg.async_get_device(identifiers={(DOMAIN, entry.entry_id)}) + assert "Removing improper device Forecast_legacy" in caplog.text From d76c16fa3a2e74cc9df9e43ce1d297589774b3e9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 Oct 2023 10:05:16 +0100 Subject: [PATCH 945/968] Update frontend to 20231030.0 (#103086) --- 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 a47ef38264e..b1eaaaf77e1 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==20231027.0"] + "requirements": ["home-assistant-frontend==20231030.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5d68cead747..cd1623c7d0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 51697865b8a..4adfc711309 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1328f70970e..aa37475486d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231027.0 +home-assistant-frontend==20231030.0 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From abaeacbd6b690c227de3ebb28f65e0b60a8502ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 10:05:03 +0100 Subject: [PATCH 946/968] Fix restore state for light when saved attribute is None (#103096) --- .../components/light/reproduce_state.py | 18 +++---- .../components/light/test_reproduce_state.py | 50 +++++++++++++++++++ 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/reproduce_state.py b/homeassistant/components/light/reproduce_state.py index 15141b6d428..f055f02ebda 100644 --- a/homeassistant/components/light/reproduce_state.py +++ b/homeassistant/components/light/reproduce_state.py @@ -149,31 +149,29 @@ async def _async_reproduce_state( service = SERVICE_TURN_ON for attr in ATTR_GROUP: # All attributes that are not colors - if attr in state.attributes: - service_data[attr] = state.attributes[attr] + if (attr_state := state.attributes.get(attr)) is not None: + service_data[attr] = attr_state if ( state.attributes.get(ATTR_COLOR_MODE, ColorMode.UNKNOWN) != ColorMode.UNKNOWN ): color_mode = state.attributes[ATTR_COLOR_MODE] - if color_mode_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): - if color_mode_attr.state_attr not in state.attributes: + if cm_attr := COLOR_MODE_TO_ATTRIBUTE.get(color_mode): + if (cm_attr_state := state.attributes.get(cm_attr.state_attr)) is None: _LOGGER.warning( "Color mode %s specified but attribute %s missing for: %s", color_mode, - color_mode_attr.state_attr, + cm_attr.state_attr, state.entity_id, ) return - service_data[color_mode_attr.parameter] = state.attributes[ - color_mode_attr.state_attr - ] + service_data[cm_attr.parameter] = cm_attr_state else: # Fall back to Choosing the first color that is specified for color_attr in COLOR_GROUP: - if color_attr in state.attributes: - service_data[color_attr] = state.attributes[color_attr] + if (color_attr_state := state.attributes.get(color_attr)) is not None: + service_data[color_attr] = color_attr_state break elif state.state == STATE_OFF: diff --git a/tests/components/light/test_reproduce_state.py b/tests/components/light/test_reproduce_state.py index f36b8180560..816bde430e7 100644 --- a/tests/components/light/test_reproduce_state.py +++ b/tests/components/light/test_reproduce_state.py @@ -22,6 +22,20 @@ VALID_RGBW_COLOR = {"rgbw_color": (255, 63, 111, 10)} VALID_RGBWW_COLOR = {"rgbww_color": (255, 63, 111, 10, 20)} VALID_XY_COLOR = {"xy_color": (0.59, 0.274)} +NONE_BRIGHTNESS = {"brightness": None} +NONE_FLASH = {"flash": None} +NONE_EFFECT = {"effect": None} +NONE_TRANSITION = {"transition": None} +NONE_COLOR_NAME = {"color_name": None} +NONE_COLOR_TEMP = {"color_temp": None} +NONE_HS_COLOR = {"hs_color": None} +NONE_KELVIN = {"kelvin": None} +NONE_PROFILE = {"profile": None} +NONE_RGB_COLOR = {"rgb_color": None} +NONE_RGBW_COLOR = {"rgbw_color": None} +NONE_RGBWW_COLOR = {"rgbww_color": None} +NONE_XY_COLOR = {"xy_color": None} + async def test_reproducing_states( hass: HomeAssistant, caplog: pytest.LogCaptureFixture @@ -237,3 +251,39 @@ async def test_deprecation_warning( ) assert len(turn_on_calls) == 1 assert DEPRECATION_WARNING % ["brightness_pct"] in caplog.text + + +@pytest.mark.parametrize( + "saved_state", + ( + NONE_BRIGHTNESS, + NONE_FLASH, + NONE_EFFECT, + NONE_TRANSITION, + NONE_COLOR_NAME, + NONE_COLOR_TEMP, + NONE_HS_COLOR, + NONE_KELVIN, + NONE_PROFILE, + NONE_RGB_COLOR, + NONE_RGBW_COLOR, + NONE_RGBWW_COLOR, + NONE_XY_COLOR, + ), +) +async def test_filter_none(hass: HomeAssistant, saved_state) -> None: + """Test filtering of parameters which are None.""" + hass.states.async_set("light.entity", "off", {}) + + turn_on_calls = async_mock_service(hass, "light", "turn_on") + + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + + assert len(turn_on_calls) == 1 + assert turn_on_calls[0].domain == "light" + assert dict(turn_on_calls[0].data) == {"entity_id": "light.entity"} + + # This should do nothing, the light is already in the desired state + hass.states.async_set("light.entity", "on", {}) + await async_reproduce_state(hass, [State("light.entity", "on", saved_state)]) + assert len(turn_on_calls) == 1 From 957998ea8dcd83942b10744c120e641718be01d2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:34 +0100 Subject: [PATCH 947/968] Fix google_tasks todo tests (#103098) --- tests/components/google_tasks/test_todo.py | 28 +++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/tests/components/google_tasks/test_todo.py b/tests/components/google_tasks/test_todo.py index 5dc7f10fea0..e19ac1272cd 100644 --- a/tests/components/google_tasks/test_todo.py +++ b/tests/components/google_tasks/test_todo.py @@ -30,6 +30,12 @@ LIST_TASKS_RESPONSE = { "items": [], } +LIST_TASKS_RESPONSE_WATER = { + "items": [ + {"id": "some-task-id", "title": "Water", "status": "needsAction"}, + ], +} + @pytest.fixture def platforms() -> list[str]: @@ -198,8 +204,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -215,7 +221,7 @@ async def test_create_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -234,12 +240,12 @@ async def test_update_todo_list_item( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda", "status": "completed"}, + {"item": "some-task-id", "rename": "Soda", "status": "completed"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -255,7 +261,7 @@ async def test_update_todo_list_item( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -274,12 +280,12 @@ async def test_partial_update_title( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "summary": "Soda"}, + {"item": "some-task-id", "rename": "Soda"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) @@ -295,7 +301,7 @@ async def test_partial_update_title( [ [ LIST_TASK_LIST_RESPONSE, - LIST_TASKS_RESPONSE, + LIST_TASKS_RESPONSE_WATER, EMPTY_RESPONSE, # update LIST_TASKS_RESPONSE, # refresh after update ] @@ -314,12 +320,12 @@ async def test_partial_update_status( state = hass.states.get("todo.my_tasks") assert state - assert state.state == "0" + assert state.state == "1" await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "some-task-id", "status": "needs_action"}, + {"item": "some-task-id", "status": "needs_action"}, target={"entity_id": "todo.my_tasks"}, blocking=True, ) From 26b7e94c4f19e26e809e02b5145db8a66b57b3c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:03:54 +0100 Subject: [PATCH 948/968] Fix shopping_list todo tests (#103100) --- tests/components/shopping_list/test_todo.py | 83 ++++++++------------- 1 file changed, 31 insertions(+), 52 deletions(-) diff --git a/tests/components/shopping_list/test_todo.py b/tests/components/shopping_list/test_todo.py index ab28c6cbe6d..681ccea60ac 100644 --- a/tests/components/shopping_list/test_todo.py +++ b/tests/components/shopping_list/test_todo.py @@ -7,7 +7,6 @@ import pytest from homeassistant.components.todo import DOMAIN as TODO_DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -115,18 +114,18 @@ async def test_get_items( assert state.state == "1" -async def test_create_item( +async def test_add_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating shopping_list item and listing it.""" + """Test adding shopping_list item and listing it.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -142,38 +141,18 @@ async def test_create_item( assert state assert state.state == "1" - # Add a completed item - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "paper", "status": "completed"}, - target={"entity_id": TEST_ENTITY}, - blocking=True, - ) - items = await ws_get_items() - assert len(items) == 2 - assert items[0]["summary"] == "soda" - assert items[0]["status"] == "needs_action" - assert items[1]["summary"] == "paper" - assert items[1]["status"] == "completed" - - state = hass.states.get(TEST_ENTITY) - assert state - assert state.state == "1" - - -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda", "status": "needs_action"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -189,9 +168,9 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": [items[0]["uid"]], + "item": [items[0]["uid"]], }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -205,20 +184,20 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, sl_setup: None, ws_req_id: Callable[[], int], ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" for _i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -234,9 +213,9 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", + "remove_item", { - "uid": uids, + "item": uids, }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -261,9 +240,9 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -285,7 +264,7 @@ async def test_update_item( TODO_DOMAIN, "update_item", { - **item, + "item": "soda", "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -315,9 +294,9 @@ async def test_partial_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": "soda", + "item": "soda", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -339,7 +318,7 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], + "item": item["uid"], "status": "completed", }, target={"entity_id": TEST_ENTITY}, @@ -362,8 +341,8 @@ async def test_partial_update_item( TODO_DOMAIN, "update_item", { - "uid": item["uid"], - "summary": "other summary", + "item": item["uid"], + "rename": "other summary", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -389,13 +368,13 @@ async def test_update_invalid_item( ) -> None: """Test updating a todo item that does not exist.""" - with pytest.raises(HomeAssistantError, match="was not found"): + with pytest.raises(ValueError, match="Unable to find"): await hass.services.async_call( TODO_DOMAIN, "update_item", { - "uid": "invalid-uid", - "summary": "Example task", + "item": "invalid-uid", + "rename": "Example task", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -443,9 +422,9 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", + "add_item", { - "summary": f"item {i}", + "item": f"item {i}", }, target={"entity_id": TEST_ENTITY}, blocking=True, @@ -481,8 +460,8 @@ async def test_move_invalid_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From fa0f679a9a92119434b17b625be1815763004592 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 31 Oct 2023 10:06:42 +0100 Subject: [PATCH 949/968] Fix todo.remove_item frontend (#103108) --- homeassistant/components/todo/services.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/todo/services.yaml b/homeassistant/components/todo/services.yaml index 4d6237760ca..1bdb8aca779 100644 --- a/homeassistant/components/todo/services.yaml +++ b/homeassistant/components/todo/services.yaml @@ -42,5 +42,6 @@ remove_item: - todo.TodoListEntityFeature.DELETE_TODO_ITEM fields: item: + required: true selector: - object: + text: From 777ffe694605ba0a9adffe7e3896dbf9bb5ecd1d Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 10:35:51 +0100 Subject: [PATCH 950/968] Fix client id label in ViCare integration (#103111) --- homeassistant/components/vicare/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vicare/strings.json b/homeassistant/components/vicare/strings.json index 0700d5d6f0e..056a4df7920 100644 --- a/homeassistant/components/vicare/strings.json +++ b/homeassistant/components/vicare/strings.json @@ -3,11 +3,11 @@ "flow_title": "{name} ({host})", "step": { "user": { - "description": "Set up ViCare integration. To generate API key go to https://developer.viessmann.com", + "description": "Set up ViCare integration. To generate client ID go to https://app.developer.viessmann.com", "data": { "username": "[%key:common::config_flow::data::email%]", "password": "[%key:common::config_flow::data::password%]", - "client_id": "[%key:common::config_flow::data::api_key%]", + "client_id": "Client ID", "heating_type": "Heating type" } } From e309bd764be068c1899eb1e94ff2cfc1a000b58e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 31 Oct 2023 11:32:17 +0100 Subject: [PATCH 951/968] Abort config flow if Google Tasks API is not enabled (#103114) Co-authored-by: Martin Hjelmare --- .../components/google_tasks/config_flow.py | 28 ++++ .../components/google_tasks/strings.json | 4 +- .../fixtures/api_not_enabled_response.json | 15 +++ .../google_tasks/test_config_flow.py | 123 +++++++++++++++++- 4 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/components/google_tasks/fixtures/api_not_enabled_response.json diff --git a/homeassistant/components/google_tasks/config_flow.py b/homeassistant/components/google_tasks/config_flow.py index 77570f0377f..b8e5e26f42c 100644 --- a/homeassistant/components/google_tasks/config_flow.py +++ b/homeassistant/components/google_tasks/config_flow.py @@ -2,6 +2,13 @@ import logging from typing import Any +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest + +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN, OAUTH2_SCOPES @@ -28,3 +35,24 @@ class OAuth2FlowHandler( "access_type": "offline", "prompt": "consent", } + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow.""" + try: + resource = build( + "tasks", + "v1", + credentials=Credentials(token=data[CONF_TOKEN][CONF_ACCESS_TOKEN]), + ) + cmd: HttpRequest = resource.tasklists().list() + await self.hass.async_add_executor_job(cmd.execute) + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + self.logger.exception("Unknown error occurred: %s", ex) + return self.async_abort(reason="unknown") + return self.async_create_entry(title=self.flow_impl.name, data=data) diff --git a/homeassistant/components/google_tasks/strings.json b/homeassistant/components/google_tasks/strings.json index e7dbbc2b625..f15c31f42d4 100644 --- a/homeassistant/components/google_tasks/strings.json +++ b/homeassistant/components/google_tasks/strings.json @@ -15,7 +15,9 @@ "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", - "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]" + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "access_not_configured": "Unable to access the Google API:\n\n{message}", + "unknown": "[%key:common::config_flow::error::unknown%]" }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/components/google_tasks/fixtures/api_not_enabled_response.json b/tests/components/google_tasks/fixtures/api_not_enabled_response.json new file mode 100644 index 00000000000..75ecfddab20 --- /dev/null +++ b/tests/components/google_tasks/fixtures/api_not_enabled_response.json @@ -0,0 +1,15 @@ +{ + "error": { + "code": 403, + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "errors": [ + { + "message": "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "domain": "usageLimits", + "reason": "accessNotConfigured", + "extendedHelp": "https://console.developers.google.com" + } + ], + "status": "PERMISSION_DENIED" + } +} diff --git a/tests/components/google_tasks/test_config_flow.py b/tests/components/google_tasks/test_config_flow.py index b05e1eb108d..e92da605697 100644 --- a/tests/components/google_tasks/test_config_flow.py +++ b/tests/components/google_tasks/test_config_flow.py @@ -2,6 +2,9 @@ from unittest.mock import patch +from googleapiclient.errors import HttpError +from httplib2 import Response + from homeassistant import config_entries from homeassistant.components.google_tasks.const import ( DOMAIN, @@ -9,8 +12,11 @@ from homeassistant.components.google_tasks.const import ( OAUTH2_TOKEN, ) from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from tests.common import load_fixture + CLIENT_ID = "1234" CLIENT_SECRET = "5678" @@ -59,8 +65,119 @@ async def test_full_flow( with patch( "homeassistant.components.google_tasks.async_setup_entry", return_value=True - ) as mock_setup: - await hass.config_entries.flow.async_configure(result["flow_id"]) - + ) as mock_setup, patch("homeassistant.components.google_tasks.config_flow.build"): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.CREATE_ENTRY assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 + + +async def test_api_not_enabled( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if api is not enabled.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=HttpError( + Response({"status": "403"}), + bytes(load_fixture("google_tasks/api_not_enabled_response.json"), "utf-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "Google Tasks API has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/tasks.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +async def test_general_exception( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, + setup_credentials, +) -> None: + """Check flow aborts if exception happens.""" + result = await hass.config_entries.flow.async_init( + "google_tasks", context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + "&scope=https://www.googleapis.com/auth/tasks" + "&access_type=offline&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.google_tasks.config_flow.build", + side_effect=Exception, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" From 9ae29e243df7fdeb7e8c26ca67f41f77a769adc4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Oct 2023 13:30:10 +0100 Subject: [PATCH 952/968] Bumped version to 2023.11.0b4 --- 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 4587f1d37a1..a21d7a8f647 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index d6070c019ae..b479c69aa54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b3" +version = "2023.11.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 19479b2a68654fb51c23059ac9cd0b30cde2f96e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:58 +0100 Subject: [PATCH 953/968] Fix local_todo todo tests (#103099) --- tests/components/local_todo/test_todo.py | 46 ++++++++++++------------ 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/tests/components/local_todo/test_todo.py b/tests/components/local_todo/test_todo.py index 8a7e38c9773..39e9264d45a 100644 --- a/tests/components/local_todo/test_todo.py +++ b/tests/components/local_todo/test_todo.py @@ -79,13 +79,13 @@ async def ws_move_item( return move -async def test_create_item( +async def test_add_item( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test creating a todo item.""" + """Test adding a todo item.""" state = hass.states.get(TEST_ENTITY) assert state @@ -93,8 +93,8 @@ async def test_create_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -110,16 +110,16 @@ async def test_create_item( assert state.state == "1" -async def test_delete_item( +async def test_remove_item( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting a todo item.""" + """Test removing a todo item.""" await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "replace batteries"}, + "add_item", + {"item": "replace batteries"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -136,8 +136,8 @@ async def test_delete_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": [items[0]["uid"]]}, + "remove_item", + {"item": [items[0]["uid"]]}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -150,17 +150,17 @@ async def test_delete_item( assert state.state == "0" -async def test_bulk_delete( +async def test_bulk_remove( hass: HomeAssistant, setup_integration: None, ws_get_items: Callable[[], Awaitable[dict[str, str]]], ) -> None: - """Test deleting multiple todo items.""" + """Test removing multiple todo items.""" for i in range(0, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"soda #{i}"}, + "add_item", + {"item": f"soda #{i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -175,8 +175,8 @@ async def test_bulk_delete( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": uids}, + "remove_item", + {"item": uids}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -199,8 +199,8 @@ async def test_update_item( # Create new item await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "soda"}, + "add_item", + {"item": "soda"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -220,7 +220,7 @@ async def test_update_item( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": item["uid"], "status": "completed"}, + {"item": item["uid"], "status": "completed"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -276,8 +276,8 @@ async def test_move_item( for i in range(1, 5): await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": f"item {i}"}, + "add_item", + {"item": f"item {i}"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) @@ -334,8 +334,8 @@ async def test_move_item_previous_unknown( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "item 1"}, + "add_item", + {"item": "item 1"}, target={"entity_id": TEST_ENTITY}, blocking=True, ) From a48e63aa28582f24d1a0948953361ce5c8cff0c6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 02:38:18 +0100 Subject: [PATCH 954/968] Fix todoist todo tests (#103101) --- tests/components/todoist/test_todo.py | 46 +++++++-------------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/tests/components/todoist/test_todo.py b/tests/components/todoist/test_todo.py index bbfaf6c493b..a14f362ea5b 100644 --- a/tests/components/todoist/test_todo.py +++ b/tests/components/todoist/test_todo.py @@ -56,12 +56,12 @@ async def test_todo_item_state( @pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_todo_list_item( +async def test_add_todo_list_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for creating a To-do Item.""" + """Test for adding a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -75,8 +75,8 @@ async def test_create_todo_list_item( await hass.services.async_call( TODO_DOMAIN, - "create_item", - {"summary": "Soda"}, + "add_item", + {"item": "Soda"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -92,30 +92,6 @@ async def test_create_todo_list_item( assert state.state == "1" -@pytest.mark.parametrize(("tasks"), [[]]) -async def test_create_completed_item_unsupported( - hass: HomeAssistant, - setup_integration: None, - api: AsyncMock, -) -> None: - """Test for creating a To-do Item that is already completed.""" - - state = hass.states.get("todo.name") - assert state - assert state.state == "0" - - api.add_task = AsyncMock() - - with pytest.raises(ValueError, match="Only active tasks"): - await hass.services.async_call( - TODO_DOMAIN, - "create_item", - {"summary": "Soda", "status": "completed"}, - target={"entity_id": "todo.name"}, - blocking=True, - ) - - @pytest.mark.parametrize( ("tasks"), [[make_api_task(id="task-id-1", content="Soda", is_completed=False)]] ) @@ -141,7 +117,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "completed"}, + {"item": "task-id-1", "status": "completed"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -164,7 +140,7 @@ async def test_update_todo_item_status( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "status": "needs_action"}, + {"item": "task-id-1", "status": "needs_action"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -203,7 +179,7 @@ async def test_update_todo_item_summary( await hass.services.async_call( TODO_DOMAIN, "update_item", - {"uid": "task-id-1", "summary": "Milk"}, + {"item": "task-id-1", "rename": "Milk"}, target={"entity_id": "todo.name"}, blocking=True, ) @@ -223,12 +199,12 @@ async def test_update_todo_item_summary( ] ], ) -async def test_delete_todo_item( +async def test_remove_todo_item( hass: HomeAssistant, setup_integration: None, api: AsyncMock, ) -> None: - """Test for deleting a To-do Item.""" + """Test for removing a To-do Item.""" state = hass.states.get("todo.name") assert state @@ -240,8 +216,8 @@ async def test_delete_todo_item( await hass.services.async_call( TODO_DOMAIN, - "delete_item", - {"uid": ["task-id-1", "task-id-2"]}, + "remove_item", + {"item": ["task-id-1", "task-id-2"]}, target={"entity_id": "todo.name"}, blocking=True, ) From 040ecb74e0a9d07211433b67973afee71b0882c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 Oct 2023 07:55:03 +0100 Subject: [PATCH 955/968] Add todo to core files (#103102) --- .core_files.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.core_files.yaml b/.core_files.yaml index b3e854de04b..f5ffdee9142 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -45,6 +45,7 @@ base_platforms: &base_platforms - homeassistant/components/switch/** - homeassistant/components/text/** - homeassistant/components/time/** + - homeassistant/components/todo/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** From 09ed6e9f9b57ece487315a269971074f2d0f5e97 Mon Sep 17 00:00:00 2001 From: Christopher Fenner <9592452+CFenner@users.noreply.github.com> Date: Tue, 31 Oct 2023 13:42:52 +0100 Subject: [PATCH 956/968] Handle exception introduced with recent PyViCare update (#103110) --- .../components/vicare/config_flow.py | 7 +++++-- tests/components/vicare/test_config_flow.py | 21 ++++++++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vicare/config_flow.py b/homeassistant/components/vicare/config_flow.py index a0feb8f38ea..5b2d3afa427 100644 --- a/homeassistant/components/vicare/config_flow.py +++ b/homeassistant/components/vicare/config_flow.py @@ -4,7 +4,10 @@ from __future__ import annotations import logging from typing import Any -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) import voluptuous as vol from homeassistant import config_entries @@ -53,7 +56,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.async_add_executor_job( vicare_login, self.hass, user_input ) - except PyViCareInvalidCredentialsError: + except (PyViCareInvalidConfigurationError, PyViCareInvalidCredentialsError): errors["base"] = "invalid_auth" else: return self.async_create_entry(title=VICARE_NAME, data=user_input) diff --git a/tests/components/vicare/test_config_flow.py b/tests/components/vicare/test_config_flow.py index 0774848ef11..7f70c13f0b0 100644 --- a/tests/components/vicare/test_config_flow.py +++ b/tests/components/vicare/test_config_flow.py @@ -2,7 +2,10 @@ from unittest.mock import AsyncMock, patch import pytest -from PyViCare.PyViCareUtils import PyViCareInvalidCredentialsError +from PyViCare.PyViCareUtils import ( + PyViCareInvalidConfigurationError, + PyViCareInvalidCredentialsError, +) from syrupy.assertion import SnapshotAssertion from homeassistant.components import dhcp @@ -43,6 +46,22 @@ async def test_user_create_entry( assert result["step_id"] == "user" assert result["errors"] == {} + # test PyViCareInvalidConfigurationError + with patch( + f"{MODULE}.config_flow.vicare_login", + side_effect=PyViCareInvalidConfigurationError( + {"error": "foo", "error_description": "bar"} + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + # test PyViCareInvalidCredentialsError with patch( f"{MODULE}.config_flow.vicare_login", From bfae1468d6fdac1b709dea87493cfca37e67ba2e Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 31 Oct 2023 15:15:20 +0100 Subject: [PATCH 957/968] Bump reolink-aio to 0.7.12 (#103120) --- 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 9d9d8d59e88..1c1d8dd96b1 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.7.11"] + "requirements": ["reolink-aio==0.7.12"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4adfc711309..dff43a67450 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2319,7 +2319,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa37475486d..6799e4782c3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ renault-api==0.2.0 renson-endura-delta==1.6.0 # homeassistant.components.reolink -reolink-aio==0.7.11 +reolink-aio==0.7.12 # homeassistant.components.rflink rflink==0.0.65 From e097dc02dd2fb12b958fbc2c149a6ca9edf4d134 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 31 Oct 2023 19:25:25 +0100 Subject: [PATCH 958/968] Don't try to load resources in safe mode (#103122) --- homeassistant/components/lovelace/websocket.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index c9b7cb10386..b756c2765e1 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -62,6 +62,7 @@ async def websocket_lovelace_resources( if hass.config.safe_mode: connection.send_result(msg["id"], []) + return if not resources.loaded: await resources.async_load() From 8992d15ffc133d97988615d8f17c9bb2976bf546 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:56:15 -0500 Subject: [PATCH 959/968] Bump aiohomekit to 3.0.9 (#103123) --- homeassistant/components/homekit_controller/connection.py | 4 +++- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 1d0eb9cdd83..ef806cb52bc 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -884,7 +884,9 @@ class HKDevice: self._config_changed_callbacks.add(callback_) return partial(self._remove_config_changed_callback, callback_) - async def get_characteristics(self, *args: Any, **kwargs: Any) -> dict[str, Any]: + async def get_characteristics( + self, *args: Any, **kwargs: Any + ) -> dict[tuple[int, int], dict[str, Any]]: """Read latest state from homekit accessory.""" return await self.pairing.get_characteristics(*args, **kwargs) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ff918396640..91fd199e17c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -14,6 +14,6 @@ "documentation": "https://www.home-assistant.io/integrations/homekit_controller", "iot_class": "local_push", "loggers": ["aiohomekit", "commentjson"], - "requirements": ["aiohomekit==3.0.8"], + "requirements": ["aiohomekit==3.0.9"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index dff43a67450..751b7af1598 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -255,7 +255,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6799e4782c3..065a0a44d73 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -233,7 +233,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.8 +aiohomekit==3.0.9 # homeassistant.components.emulated_hue # homeassistant.components.http From f0a06efa1f90892b994d597de400a76781129e0c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 12:38:05 -0500 Subject: [PATCH 960/968] Fix race in starting reauth flows (#103130) --- homeassistant/config_entries.py | 31 +++++++++++++++++++++----- tests/components/smarttub/test_init.py | 1 + tests/test_config_entries.py | 14 ++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 02a9dd9dade..2b8f1ec4065 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -223,6 +223,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_reauth_lock", "_tasks", "_background_tasks", "_integration_for_domain", @@ -321,6 +322,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + # Reauth lock to prevent concurrent reauth flows + self._reauth_lock = asyncio.Lock() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() @@ -727,12 +730,28 @@ class ConfigEntry: data: dict[str, Any] | None = None, ) -> None: """Start a reauth flow.""" + # We will check this again in the task when we hold the lock, + # but we also check it now to try to avoid creating the task. if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): # Reauth flow already in progress for this entry return - hass.async_create_task( - hass.config_entries.flow.async_init( + self._async_init_reauth(hass, context, data), + f"config entry reauth {self.title} {self.domain} {self.entry_id}", + ) + + async def _async_init_reauth( + self, + hass: HomeAssistant, + context: dict[str, Any] | None = None, + data: dict[str, Any] | None = None, + ) -> None: + """Start a reauth flow.""" + async with self._reauth_lock: + if any(self.async_get_active_flows(hass, {SOURCE_REAUTH})): + # Reauth flow already in progress for this entry + return + await hass.config_entries.flow.async_init( self.domain, context={ "source": SOURCE_REAUTH, @@ -742,9 +761,7 @@ class ConfigEntry: } | (context or {}), data=self.data | (data or {}), - ), - f"config entry reauth {self.title} {self.domain} {self.entry_id}", - ) + ) @callback def async_get_active_flows( @@ -754,7 +771,9 @@ class ConfigEntry: return ( flow for flow in hass.config_entries.flow.async_progress_by_handler( - self.domain, match_context={"entry_id": self.entry_id} + self.domain, + match_context={"entry_id": self.entry_id}, + include_uninitialized=True, ) if flow["context"].get("source") in sources ) diff --git a/tests/components/smarttub/test_init.py b/tests/components/smarttub/test_init.py index 0e88f3ed7c7..929ad687e11 100644 --- a/tests/components/smarttub/test_init.py +++ b/tests/components/smarttub/test_init.py @@ -42,6 +42,7 @@ async def test_setup_auth_failed( config_entry.add_to_hass(hass) with patch.object(hass.config_entries.flow, "async_init") as mock_flow_init: await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.SETUP_ERROR mock_flow_init.assert_called_with( DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index d17c724cb2a..eb771b7e6a6 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3791,6 +3791,20 @@ async def test_reauth(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 2 + # Abort all existing flows + for flow in hass.config_entries.flow.async_progress(): + hass.config_entries.flow.async_abort(flow["flow_id"]) + await hass.async_block_till_done() + + # Check that we can't start duplicate reauth flows + # without blocking between flows + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + entry.async_start_reauth(hass, {"extra_context": "some_extra_context"}) + await hass.async_block_till_done() + assert len(hass.config_entries.flow.async_progress()) == 1 + async def test_get_active_flows(hass: HomeAssistant) -> None: """Test the async_get_active_flows helper.""" From 9910f9e0aedd34613a789b11ef65017816005312 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 31 Oct 2023 19:43:21 +0100 Subject: [PATCH 961/968] Bumped version to 2023.11.0b5 --- 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 a21d7a8f647..8022275b824 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index b479c69aa54..61c18f5d4ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b4" +version = "2023.11.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c5f21fefbe637e145e552281648a082ed1bb1537 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Wed, 1 Nov 2023 01:54:51 +0100 Subject: [PATCH 962/968] Bump python-kasa to 0.5.4 for tplink (#103038) --- homeassistant/components/tplink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tplink/test_init.py | 4 +++- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index d13adb8ec47..e0ac41bdec6 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -169,5 +169,5 @@ "iot_class": "local_polling", "loggers": ["kasa"], "quality_scale": "platinum", - "requirements": ["python-kasa[speedups]==0.5.3"] + "requirements": ["python-kasa[speedups]==0.5.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 751b7af1598..f664cb7e7df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2141,7 +2141,7 @@ python-join-api==0.0.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.lirc # python-lirc==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 065a0a44d73..c8c884e92b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ python-izone==1.2.9 python-juicenet==1.1.0 # homeassistant.components.tplink -python-kasa[speedups]==0.5.3 +python-kasa[speedups]==0.5.4 # homeassistant.components.matter python-matter-server==4.0.0 diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py index 4206c0de6ad..c40560d2a89 100644 --- a/tests/components/tplink/test_init.py +++ b/tests/components/tplink/test_init.py @@ -29,7 +29,9 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_configuring_tplink_causes_discovery(hass: HomeAssistant) -> None: """Test that specifying empty config does discovery.""" - with patch("homeassistant.components.tplink.Discover.discover") as discover: + with patch("homeassistant.components.tplink.Discover.discover") as discover, patch( + "homeassistant.components.tplink.Discover.discover_single" + ): discover.return_value = {MagicMock(): MagicMock()} await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}}) await hass.async_block_till_done() From ea2d2ba7b7c8c2df11d27d19e560dc091f75a387 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 Oct 2023 19:48:33 -0700 Subject: [PATCH 963/968] Improve fitbit oauth token error handling in config flow (#103131) * Improve fitbit oauth token error handling in config flow * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Update tests with updated error reason --------- Co-authored-by: Martin Hjelmare --- .../fitbit/application_credentials.py | 13 +++-- .../components/fitbit/config_flow.py | 15 ++++++ homeassistant/components/fitbit/strings.json | 5 +- tests/components/fitbit/test_config_flow.py | 53 ++++++++++++++++++- 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/fitbit/application_credentials.py b/homeassistant/components/fitbit/application_credentials.py index e66b9ca9014..caf0384eca2 100644 --- a/homeassistant/components/fitbit/application_credentials.py +++ b/homeassistant/components/fitbit/application_credentials.py @@ -59,13 +59,16 @@ class FitbitOAuth2Implementation(AuthImplementation): resp = await session.post(self.token_url, data=data, headers=self._headers) resp.raise_for_status() except aiohttp.ClientResponseError as err: - error_body = await resp.text() - _LOGGER.debug("Client response error body: %s", error_body) + if _LOGGER.isEnabledFor(logging.DEBUG): + error_body = await resp.text() if not session.closed else "" + _LOGGER.debug( + "Client response error status=%s, body=%s", err.status, error_body + ) if err.status == HTTPStatus.UNAUTHORIZED: - raise FitbitAuthException from err - raise FitbitApiException from err + raise FitbitAuthException(f"Unauthorized error: {err}") from err + raise FitbitApiException(f"Server error response: {err}") from err except aiohttp.ClientError as err: - raise FitbitApiException from err + raise FitbitApiException(f"Client connection error: {err}") from err return cast(dict, await resp.json()) @property diff --git a/homeassistant/components/fitbit/config_flow.py b/homeassistant/components/fitbit/config_flow.py index ee2340e7587..dd7e79e2c65 100644 --- a/homeassistant/components/fitbit/config_flow.py +++ b/homeassistant/components/fitbit/config_flow.py @@ -53,6 +53,21 @@ class OAuth2FlowHandler( return self.async_show_form(step_id="reauth_confirm") return await self.async_step_user() + async def async_step_creation( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Create config entry from external data with Fitbit specific error handling.""" + try: + return await super().async_step_creation() + except FitbitAuthException as err: + _LOGGER.error( + "Failed to authenticate when creating Fitbit credentials: %s", err + ) + return self.async_abort(reason="invalid_auth") + except FitbitApiException as err: + _LOGGER.error("Failed to create Fitbit credentials: %s", err) + return self.async_abort(reason="cannot_connect") + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Create an entry for the flow, or update existing entry.""" diff --git a/homeassistant/components/fitbit/strings.json b/homeassistant/components/fitbit/strings.json index 2d74408a73f..889b56f1bbd 100644 --- a/homeassistant/components/fitbit/strings.json +++ b/homeassistant/components/fitbit/strings.json @@ -16,9 +16,10 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]", "wrong_account": "The user credentials provided do not match this Fitbit account." diff --git a/tests/components/fitbit/test_config_flow.py b/tests/components/fitbit/test_config_flow.py index cf2d5d17f22..d51379c9adc 100644 --- a/tests/components/fitbit/test_config_flow.py +++ b/tests/components/fitbit/test_config_flow.py @@ -88,6 +88,57 @@ async def test_full_flow( } +@pytest.mark.parametrize( + ("status_code", "error_reason"), + [ + (HTTPStatus.UNAUTHORIZED, "invalid_auth"), + (HTTPStatus.INTERNAL_SERVER_ERROR, "cannot_connect"), + ], +) +async def test_token_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + current_request_with_host: None, + profile: None, + setup_credentials: None, + status_code: HTTPStatus, + error_reason: str, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": REDIRECT_URL, + }, + ) + assert result["type"] == FlowResultType.EXTERNAL_STEP + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + f"&redirect_uri={REDIRECT_URL}" + f"&state={state}" + "&scope=activity+heartrate+nutrition+profile+settings+sleep+weight&prompt=consent" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + status=status_code, + ) + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == error_reason + + @pytest.mark.parametrize( ("http_status", "json", "error_reason"), [ @@ -460,7 +511,7 @@ async def test_reauth_flow( "refresh_token": "updated-refresh-token", "access_token": "updated-access-token", "type": "Bearer", - "expires_in": 60, + "expires_in": "60", }, ) From bcea021c14422ce0dbcf4db8af5f403529f19137 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 Oct 2023 16:29:22 -0500 Subject: [PATCH 964/968] Allow non-admins to subscribe to the issue registry updated event (#103145) --- homeassistant/auth/permissions/events.py | 2 ++ homeassistant/components/websocket_api/commands.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/auth/permissions/events.py b/homeassistant/auth/permissions/events.py index d50da96a39f..aec23331664 100644 --- a/homeassistant/auth/permissions/events.py +++ b/homeassistant/auth/permissions/events.py @@ -19,6 +19,7 @@ from homeassistant.const import ( from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED +from homeassistant.helpers.issue_registry import EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED # These are events that do not contain any sensitive data # Except for state_changed, which is handled accordingly. @@ -28,6 +29,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[str]] = { EVENT_CORE_CONFIG_UPDATE, EVENT_DEVICE_REGISTRY_UPDATED, EVENT_ENTITY_REGISTRY_UPDATED, + EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED, EVENT_LOVELACE_UPDATED, EVENT_PANELS_UPDATED, EVENT_RECORDER_5MIN_STATISTICS_GENERATED, diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a29bee86116..2dfa48c28fe 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -57,6 +57,8 @@ from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" +_LOGGER = logging.getLogger(__name__) + @callback def async_register_commands( @@ -132,7 +134,12 @@ def handle_subscribe_events( event_type = msg["event_type"] if event_type not in SUBSCRIBE_ALLOWLIST and not connection.user.is_admin: - raise Unauthorized + _LOGGER.error( + "Refusing to allow %s to subscribe to event %s", + connection.user.name, + event_type, + ) + raise Unauthorized(user_id=connection.user.id) if event_type == EVENT_STATE_CHANGED: forward_events = callback( From aa5ea5ebc33e52dec5c666a19b42384a722ebad8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 Nov 2023 09:25:56 +0100 Subject: [PATCH 965/968] Fix mqtt is not reloading without yaml config (#103159) --- homeassistant/components/mqtt/__init__.py | 2 +- tests/components/mqtt/test_init.py | 36 +++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ac229cb677f..be283271dee 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -233,7 +233,7 @@ async def async_check_config_schema( ) -> None: """Validate manually configured MQTT items.""" mqtt_data = get_mqtt_data(hass) - mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml[DOMAIN] + mqtt_config: list[dict[str, list[ConfigType]]] = config_yaml.get(DOMAIN, {}) for mqtt_config_item in mqtt_config: for domain, config_items in mqtt_config_item.items(): schema = mqtt_data.reload_schema[domain] diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b071252ea64..2aa8de388b1 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -3974,3 +3974,39 @@ async def test_reload_with_invalid_config( # Test nothing changed as loading the config failed assert hass.states.get("sensor.test") is not None + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": [ + { + "sensor": { + "name": "test", + "state_topic": "test-topic", + } + }, + ] + } + ], +) +async def test_reload_with_empty_config( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test reloading yaml config fails.""" + await mqtt_mock_entry() + assert hass.states.get("sensor.test") is not None + + # Reload with an empty config and assert again + with patch("homeassistant.config.load_yaml_config_file", return_value={}): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test") is None From cfa2f2ce6189e9c54d4fb83d5a572478cd46ee3b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 Nov 2023 11:05:17 +0100 Subject: [PATCH 966/968] Update frontend to 20231030.1 (#103163) --- 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 b1eaaaf77e1..6fffc0e8acd 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==20231030.0"] + "requirements": ["home-assistant-frontend==20231030.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cd1623c7d0d..a70bcf4524a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.74.0 hassil==1.2.5 home-assistant-bluetooth==1.10.4 -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 home-assistant-intents==2023.10.16 httpx==0.25.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index f664cb7e7df..77633056811 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c8c884e92b2..666c3ea4dc6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -796,7 +796,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20231030.0 +home-assistant-frontend==20231030.1 # homeassistant.components.conversation home-assistant-intents==2023.10.16 From 18acec32b862def367c6de2d2807c5d186cc5662 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Nov 2023 11:22:25 +0100 Subject: [PATCH 967/968] Bumped version to 2023.11.0b6 --- 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 8022275b824..1ec9532e11f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index 61c18f5d4ba..aa2a3c66b0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b5" +version = "2023.11.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4ce859b4e4ae68f21af51a21c194a5f6a36cd554 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 Nov 2023 11:24:41 +0100 Subject: [PATCH 968/968] Bumped version to 2023.11.0 --- 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 1ec9532e11f..28241ef15f4 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from typing import Final APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 MINOR_VERSION: Final = 11 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) diff --git a/pyproject.toml b/pyproject.toml index aa2a3c66b0e..7efa6915a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.11.0b6" +version = "2023.11.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"